diff --git a/docs/example-usage.md b/docs/example-usage.md new file mode 100644 index 0000000..dea1a31 --- /dev/null +++ b/docs/example-usage.md @@ -0,0 +1,114 @@ +# Survey Editor Event Listener Example + +The SurveyEditor now supports a simple event-driven architecture with a single `survey-changed` event. + +## Basic Usage + +```typescript +import { SurveyEditor } from '../src/survey-editor'; + +// Create editor instance +const editor = new SurveyEditor(survey); + +// Listen for any survey changes (both uncommitted changes and commits) +editor.on('survey-changed', (data) => { + if (data.isCommit) { + console.log('Changes committed:', data.description); + // Save to persistent storage + saveToDatabase(editor.toJson()); + } else { + console.log('Survey modified (uncommitted)'); + // Auto-save to session storage for recovery + updateSessionData(editor.toJson()); + } + + console.log('Has uncommitted changes:', data.hasUncommittedChanges); +}); +``` + +## Session Data Auto-Save Example + +```typescript +class EditorSession { + private editor: SurveyEditor; + private sessionKey: string; + + constructor(editor: SurveyEditor, sessionKey: string) { + this.editor = editor; + this.sessionKey = sessionKey; + + // Set up auto-save on any change + this.editor.on('survey-changed', this.handleSurveyChanged.bind(this)); + } + + private handleSurveyChanged(data: { hasUncommittedChanges: boolean; isCommit: boolean; description?: string }) { + if (data.isCommit) { + // Save committed state to persistent storage + localStorage.setItem( + this.sessionKey, + JSON.stringify(this.editor.toJson()) + ); + + // Clear draft since it's now committed + sessionStorage.removeItem(`${this.sessionKey}_draft`); + } else if (data.hasUncommittedChanges) { + // Save to session storage for recovery + sessionStorage.setItem( + `${this.sessionKey}_draft`, + JSON.stringify(this.editor.toJson()) + ); + } + } + + cleanup() { + this.editor.clearAllListeners(); + } +} +``` + +## Analytics Example + +```typescript +class EditorAnalytics { + constructor(editor: SurveyEditor) { + // Track all survey changes + editor.on('survey-changed', (data) => { + if (data.isCommit) { + this.trackEvent('survey_changes_committed', { + description: data.description, + hasUncommittedChanges: data.hasUncommittedChanges + }); + } else { + this.trackEvent('survey_modified', { + hasUncommittedChanges: data.hasUncommittedChanges + }); + } + }); + } + + private trackEvent(eventName: string, properties?: any) { + // Send to your analytics service + analytics.track(eventName, properties); + } +} +``` + +## Available Events + +| Event Type | Data | Description | +|------------|------|-------------| +| `survey-changed` | `{ hasUncommittedChanges: boolean; isCommit: boolean; description?: string }` | Fired when survey content changes or is committed | + +### Event Data Properties + +- `hasUncommittedChanges`: Whether the editor has uncommitted changes +- `isCommit`: `true` if this event was triggered by a commit, `false` if by a modification +- `description`: Only present when `isCommit` is `true`, contains the commit description + +## Best Practices + +1. **Remove listeners**: Always call `editor.off()` or `editor.clearAllListeners()` when cleaning up +2. **Error handling**: Event listeners are wrapped in try-catch, but handle errors gracefully +3. **Performance**: Be mindful of heavy operations in the `survey-changed` event as it fires on every modification +4. **Memory leaks**: Remove listeners when components unmount to prevent memory leaks +5. **Check event type**: Use the `isCommit` flag to differentiate between modifications and commits diff --git a/docs/survey-structure.md b/docs/survey-structure.md index 496cbb8..f5f58d3 100644 --- a/docs/survey-structure.md +++ b/docs/survey-structure.md @@ -1,8 +1,10 @@ +TODO: update this file with the new structure + # Survey Definition Structure ## Overview -Usually a survey is described as a set of (ordered) questions. +Usually a survey is described as a set of (ordered) questions. In this engine a survey is a hierarchical structure composed by 2 kind of elements: @@ -33,49 +35,50 @@ SurveyGroupItem(intake): ### Path into the survey tree and key rules for SurveyItem -** Survey Engine Rule ** +**Survey Engine Rule** These rules are mandatory to allow the survey engine to work correctly. -Each `SurveyItem` has a key, uniquely identify the item inside the survey. +Each `SurveyItem` has a key, uniquely identify the item inside the survey. They keys of SurveyItem elements (`SurveySingleItem` and `SurveyGroupItem`) must follow these rules: - The key is composed by a set of segments separated by a dot (.), root element has only one segment -- The key segments are set of characters WITHOUT dot +- The key segments are set of characters WITHOUT dot - The key of one SurveyItem must be prefixed by the key of it parents. -- All keys must uniquely identify one item +- All keys must uniquely identify one item -The key with several segments separated by dots represent the path to this item from the root item. In other words, the key of each item must be the path (dot separated) to this item from the root item. Each dot represent the walk through the lower level. +The key with several segments separated by dots represent the path to this item from the root item. In other words, the key of each item must be the path (dot separated) to this item from the root item. Each dot represent the walk through the lower level. Examples: - - A survey named weekly, with an item Q1: - - The root item is is keyed 'weekly', - - the item is keyed 'weekly.Q1' - - A survey named weekly with a group of question 'G1', this group containing two questions (SingleItem) Q1 and Q2: - - the root item's key is 'weekly' - - the group's key is 'weekly.G1' - - the item inside the groups have keys respectively 'weekly.G1.Q1', 'weekly.G1.Q2' -** General best practices ** +- A survey named weekly, with an item Q1: + - The root item is is keyed 'weekly', + - the item is keyed 'weekly.Q1' +- A survey named weekly with a group of question 'G1', this group containing two questions (SingleItem) Q1 and Q2: + - the root item's key is 'weekly' + - the group's key is 'weekly.G1' + - the item inside the groups have keys respectively 'weekly.G1.Q1', 'weekly.G1.Q2' + +**General best practices** - Key segments should be alphanumerical words, including underscores - As meaningful as possible - If a word separator is chosen (dash or underscore) it should be the same everywhere -** Best practices for InfluenzaNet ** +**Best practices for InfluenzaNet** Besides the previous naming rules, there are some naming convention, inherited from past platform of Influenzanet - [should] Items are named with a 'Q' followed by a question identifier (in use Q[nn] and Qcov[nn]) -- [must] Last segment of each key must be unique for all the survey. e.g. 'weekly.Q1' must be unique but also 'Q1' in the survey. You must not have another item with a key finishing by 'Q1', even if in another group. This rule because the last segment is used a the key for data export. -- [must] Question keys are arbitrary but the common questions must have the assigned key in the survey standard definition -- [should] Non common question, should have a prefix to clearly identify them as non standard question (like a namespace), for example +- [must] Last segment of each key must be unique for all the survey. e.g. 'weekly.Q1' must be unique but also 'Q1' in the survey. You must not have another item with a key finishing by 'Q1', even if in another group. This rule because the last segment is used a the key for data export. +- [must] Question keys are arbitrary but the common questions must have the assigned key in the survey standard definition +- [should] Non common question, should have a prefix to clearly identify them as non standard question (like a namespace), for example ## Survey Item Components -Components describe the properties of a SurveySingleItem (e.g. a question). +Components describe the properties of a SurveySingleItem (e.g. a question). -Every elements of a question is a component with a specific role. For example the label, the input widget or an option is a component. +Every elements of a question is a component with a specific role. For example the label, the input widget or an option is a component. Several kind of components are proposed : @@ -88,12 +91,14 @@ Components can also be described as a tree, nodes as Group components, and leaf Each component has a 'role' field, giving the purpose of the component and how the survey engine will handle it. Common roles: + - 'root' : the group component of a survey item, containing all the components of a Survey item. - 'responseGroup' : a response group component - 'helpGroup' - 'text' : a component describing text to show (for example the label of the question) Response dedicated roles: + - 'singleChoiceGroup' - dropDownGroup - multipleChoiceGroup @@ -111,6 +116,7 @@ common Fields : - `disabled` : rules for disabling component For group component only + - `order` : order of components (for Group component) Most of fields are represented as structure called `Expression`. They can be evaluated to a value (boolean, string, value) at runtime allowing to define complex rules to dynamically determine a field value. @@ -126,6 +132,7 @@ Keys are mandatory for Response component **Expression** are tree structures to represent operation to be evaluated at runtime and can produce a value. They provides dynamic property evaluation for the survey logic. An **Expression** is a simple structure that can be either: + - A typed literal value (numerical, string) - A call, with a `name` and a field `data` set of parameters with a list of `Expression` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..4330149 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,36 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + }, + rules: { + // Rule to detect unused imports and variables + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + // Additional TypeScript-specific rules + '@typescript-eslint/no-explicit-any': 'warn', + }, + }, + { + files: ['**/*.test.ts', '**/*.spec.ts'], + rules: { + // Allow unused variables in tests (common for setup/teardown) + '@typescript-eslint/no-unused-vars': 'off', + }, + } +); diff --git a/jestconfig.json b/jestconfig.json index 8379592..c8bf529 100644 --- a/jestconfig.json +++ b/jestconfig.json @@ -14,4 +14,4 @@ "json", "node" ] -} +} \ No newline at end of file diff --git a/package.json b/package.json index d159044..ca1c968 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,14 @@ { "name": "survey-engine", - "version": "1.3.2", + "version": "2.0.0-dev", "description": "Implementation of the survey engine to use in typescript/javascript projects.", "main": "index.js", + "type": "module", "scripts": { "test": "jest --config jestconfig.json", - "build": "rollup -c" + "build": "tsdown", + "lint": "eslint src --ext .ts", + "lint:fix": "eslint src --ext .ts --fix" }, "keywords": [ "survey engine" @@ -17,20 +20,15 @@ }, "homepage": "https://github.com/influenzanet/survey-engine.ts#readme", "devDependencies": { - "@rollup/plugin-commonjs": "^21.0.1", - "@rollup/plugin-node-resolve": "^13.0.6", - "@types/jest": "^29.2.0", - "jest": "^29.2.1", - "rollup": "^2.61.1", - "rollup-plugin-copy": "^3.4.0", - "rollup-plugin-delete": "^2.0.0", - "rollup-plugin-multi-input": "^1.3.1", - "rollup-plugin-peer-deps-external": "^2.2.4", - "rollup-plugin-typescript2": "^0.31.1", - "ts-jest": "^29.0.3", - "typescript": "^4.5.3" + "@types/jest": "^30.0.0", + "eslint": "^9.29.0", + "jest": "^30.0.2", + "ts-jest": "^29.4.0", + "tsdown": "^0.12.8", + "typescript": "^5.8.3", + "typescript-eslint": "^8.35.0" }, "dependencies": { - "date-fns": "^2.29.3" + "date-fns": "^4.1.0" } } diff --git a/rollup.config.js b/rollup.config.js deleted file mode 100644 index e2c67b5..0000000 --- a/rollup.config.js +++ /dev/null @@ -1,49 +0,0 @@ -import peerDepsExternal from "rollup-plugin-peer-deps-external"; -import resolve from "@rollup/plugin-node-resolve"; -import commonjs from "@rollup/plugin-commonjs"; -import typescript from "rollup-plugin-typescript2"; -import del from 'rollup-plugin-delete'; -import multiInput from 'rollup-plugin-multi-input'; -import copy from 'rollup-plugin-copy'; - -const packageJson = require("./package.json"); - -const config = { - input: [ - "src/**/*.ts" - ], - output: [ - { - //file: packageJson.main, - dir: 'build', - format: "cjs", - sourcemap: true - },/* - { - file: packageJson.module, - dir: 'build', - format: "esm", - sourcemap: true - }*/ - ], - plugins: [ - del({ - targets: 'build/*' - }), - multiInput({ relative: 'src/' }), - peerDepsExternal(), - resolve(), - commonjs(), - typescript({ - tsconfig: './tsconfig.json', - useTsconfigDeclarationDir: true, - }), - copy({ - targets: [ - { src: 'package.json', dest: 'build' } - ] - }) - ] -}; - -export default config; diff --git a/src/__tests__/cqm-parser.test.ts b/src/__tests__/cqm-parser.test.ts new file mode 100644 index 0000000..aeaf797 --- /dev/null +++ b/src/__tests__/cqm-parser.test.ts @@ -0,0 +1,712 @@ +import { parseCQM } from '../cqm-parser'; + +describe('CQM Parser', () => { + describe('Basic formatting', () => { + test('should parse plain text without formatting', () => { + const result = parseCQM('Hello world'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: 'Hello world' + }); + }); + + test('should parse bold text with **', () => { + const result = parseCQM('**bold text**'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + bold: true, + underline: false, + textColor: undefined, + italic: false, + content: 'bold text' + }); + }); + + test('should parse underlined text with __', () => { + const result = parseCQM('__underlined text__'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + bold: false, + underline: true, + textColor: undefined, + italic: false, + content: 'underlined text' + }); + }); + + test('should parse primary text with !!', () => { + const result = parseCQM('!!primary text!!'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: 'primary', + italic: false, + content: 'primary text' + }); + }); + + test('should parse accent text with ~~', () => { + const result = parseCQM('~~accent text~~'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: 'accent', + italic: false, + content: 'accent text' + }); + }); + + test('should parse italic text with //', () => { + const result = parseCQM('//italic text//'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: true, + content: 'italic text' + }); + }); + + test('should treat expressions as literal text', () => { + const result = parseCQM('Hello {{name}}, welcome!'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: 'Hello {{name}}, welcome!' + }); + }); + + test('should treat expressions with whitespace as literal text', () => { + const result = parseCQM('Hello {{ name }}, welcome!'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: 'Hello {{ name }}, welcome!' + }); + }); + }); + + describe('Mixed content', () => { + test('should parse text with multiple formatting types', () => { + const result = parseCQM('Normal **bold** __underlined__ !!primary!! ~~accent~~ //italic//'); + expect(result).toHaveLength(10); + + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: 'Normal ' + }); + + expect(result[1]).toEqual({ + bold: true, + underline: false, + textColor: undefined, + italic: false, + content: 'bold' + }); + + expect(result[2]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: ' ' + }); + + expect(result[3]).toEqual({ + bold: false, + underline: true, + textColor: undefined, + italic: false, + content: 'underlined' + }); + + expect(result[4]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: ' ' + }); + + expect(result[5]).toEqual({ + bold: false, + underline: false, + textColor: 'primary', + italic: false, + content: 'primary' + }); + + expect(result[6]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: ' ' + }); + + expect(result[7]).toEqual({ + bold: false, + underline: false, + textColor: 'accent', + italic: false, + content: 'accent' + }); + + expect(result[8]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: ' ' + }); + + expect(result[9]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: true, + content: 'italic' + }); + }); + + test('should parse text with expressions and formatting', () => { + const result = parseCQM('Hello **John** and __welcome {{name}}__'); + expect(result).toHaveLength(4); + + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: 'Hello ' + }); + + expect(result[1]).toEqual({ + bold: true, + underline: false, + textColor: undefined, + italic: false, + content: 'John' + }); + + expect(result[2]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: ' and ' + }); + + expect(result[3]).toEqual({ + bold: false, + underline: true, + textColor: undefined, + italic: false, + content: 'welcome {{name}}' + }); + }); + }); + + describe('Nested formatting', () => { + test('should handle nested bold and underline', () => { + const result = parseCQM('**bold __and underlined__ text**'); + expect(result).toHaveLength(3); + + expect(result[0]).toEqual({ + bold: true, + underline: false, + textColor: undefined, + italic: false, + content: 'bold ' + }); + + expect(result[1]).toEqual({ + bold: true, + underline: true, + textColor: undefined, + italic: false, + content: 'and underlined' + }); + + expect(result[2]).toEqual({ + bold: true, + underline: false, + textColor: undefined, + italic: false, + content: ' text' + }); + }); + + test('should handle multiple nested formatting', () => { + const result = parseCQM('**bold //italic !!primary!! text// end**'); + expect(result).toHaveLength(5); + + expect(result[0]).toEqual({ + bold: true, + underline: false, + textColor: undefined, + italic: false, + content: 'bold ' + }); + + expect(result[1]).toEqual({ + bold: true, + underline: false, + textColor: undefined, + italic: true, + content: 'italic ' + }); + + expect(result[2]).toEqual({ + bold: true, + underline: false, + textColor: 'primary', + italic: true, + content: 'primary' + }); + + expect(result[3]).toEqual({ + bold: true, + underline: false, + textColor: undefined, + italic: true, + content: ' text' + }); + + expect(result[4]).toEqual({ + bold: true, + underline: false, + textColor: undefined, + italic: false, + content: ' end' + }); + }); + + test('should handle nested primary and accent colors', () => { + const result = parseCQM('!!primary ~~accent~~ primary!!'); + expect(result).toHaveLength(3); + + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: 'primary', + italic: false, + content: 'primary ' + }); + + expect(result[1]).toEqual({ + bold: false, + underline: false, + textColor: 'accent', + italic: false, + content: 'accent' + }); + + expect(result[2]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: ' primary' + }); + }); + }); + + describe('Toggle behavior', () => { + test('should toggle formatting on and off', () => { + const result = parseCQM('normal **bold** normal **bold again**'); + expect(result).toHaveLength(4); + + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: 'normal ' + }); + + expect(result[1]).toEqual({ + bold: true, + underline: false, + textColor: undefined, + italic: false, + content: 'bold' + }); + + expect(result[2]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: ' normal ' + }); + + expect(result[3]).toEqual({ + bold: true, + underline: false, + textColor: undefined, + italic: false, + content: 'bold again' + }); + }); + + test('should toggle primary color on and off', () => { + const result = parseCQM('normal !!primary!! normal !!primary again!!'); + expect(result).toHaveLength(4); + + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: 'normal ' + }); + + expect(result[1]).toEqual({ + bold: false, + underline: false, + textColor: 'primary', + italic: false, + content: 'primary' + }); + + expect(result[2]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: ' normal ' + }); + + expect(result[3]).toEqual({ + bold: false, + underline: false, + textColor: 'primary', + italic: false, + content: 'primary again' + }); + }); + + test('should toggle accent color on and off', () => { + const result = parseCQM('normal ~~accent~~ normal ~~accent again~~'); + expect(result).toHaveLength(4); + + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: 'normal ' + }); + + expect(result[1]).toEqual({ + bold: false, + underline: false, + textColor: 'accent', + italic: false, + content: 'accent' + }); + + expect(result[2]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: ' normal ' + }); + + expect(result[3]).toEqual({ + bold: false, + underline: false, + textColor: 'accent', + italic: false, + content: 'accent again' + }); + }); + + test('should handle unclosed formatting tags', () => { + const result = parseCQM('**bold text without closing'); + expect(result).toHaveLength(1); + + expect(result[0]).toEqual({ + bold: true, + underline: false, + textColor: undefined, + italic: false, + content: 'bold text without closing' + }); + }); + }); + + describe('Edge cases', () => { + test('should handle empty string', () => { + const result = parseCQM(''); + expect(result).toHaveLength(0); + }); + + test('should handle string with only formatting markers', () => { + const result = parseCQM('****'); + expect(result).toHaveLength(0); + }); + + test('should handle expressions as literal text', () => { + const result = parseCQM('{{incomplete expression'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: '{{incomplete expression' + }); + }); + + test('should handle empty expressions as literal text', () => { + const result = parseCQM('{{}}'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: '{{}}' + }); + }); + + test('should handle single characters', () => { + const result = parseCQM('*'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: '*' + }); + }); + + test('should handle consecutive formatting markers', () => { + const result = parseCQM('**__!!//text//__!!**'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + bold: true, + underline: true, + textColor: 'primary', + italic: true, + content: 'text' + }); + }); + + test('should handle consecutive formatting markers with accent', () => { + const result = parseCQM('**__~~//text//~~__**'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + bold: true, + underline: true, + textColor: 'accent', + italic: true, + content: 'text' + }); + }); + + test('should handle mixed single and double markers', () => { + const result = parseCQM('*bold* __underline__'); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: '*bold* ' + }); + expect(result[1]).toEqual({ + bold: false, + underline: true, + textColor: undefined, + italic: false, + content: 'underline' + }); + }); + + test('should handle expressions with special characters as literal text', () => { + const result = parseCQM('{{expression_with-special.chars123}}'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: '{{expression_with-special.chars123}}' + }); + }); + + test('should handle multiple expressions as literal text', () => { + const result = parseCQM('{{first}} and {{second}}'); + expect(result).toHaveLength(1); + + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: '{{first}} and {{second}}' + }); + }); + }); + + describe('Real-world scenarios', () => { + test('should parse complex survey text with expressions as literal text', () => { + const result = parseCQM('Dear **John**, please answer the following __important__ question about !!{{topic}}!!. //Note: this is confidential.//'); + expect(result).toHaveLength(8); + + expect(result[0]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: 'Dear ' + }); + + expect(result[1]).toEqual({ + bold: true, + underline: false, + textColor: undefined, + italic: false, + content: 'John' + }); + + expect(result[2]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: ', please answer the following ' + }); + + expect(result[3]).toEqual({ + bold: false, + underline: true, + textColor: undefined, + italic: false, + content: 'important' + }); + + expect(result[4]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: ' question about ' + }); + + expect(result[5]).toEqual({ + bold: false, + underline: false, + textColor: 'primary', + italic: false, + content: '{{topic}}' + }); + + expect(result[6]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: '. ' + }); + + expect(result[7]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: true, + content: 'Note: this is confidential.' + }); + }); + + test('should parse complex text with mixed colors and formatting', () => { + const result = parseCQM('**Bold text** with !!primary color!! and ~~accent color~~ and //italic text//.'); + expect(result).toHaveLength(8); + + expect(result[0]).toEqual({ + bold: true, + underline: false, + textColor: undefined, + italic: false, + content: 'Bold text' + }); + + expect(result[1]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: ' with ' + }); + + expect(result[2]).toEqual({ + bold: false, + underline: false, + textColor: 'primary', + italic: false, + content: 'primary color' + }); + + expect(result[3]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: ' and ' + }); + + expect(result[4]).toEqual({ + bold: false, + underline: false, + textColor: 'accent', + italic: false, + content: 'accent color' + }); + + expect(result[5]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: ' and ' + }); + + expect(result[6]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: true, + content: 'italic text' + }); + + expect(result[7]).toEqual({ + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: '.' + }); + }); + }); +}); diff --git a/src/__tests__/data-parser.test.ts b/src/__tests__/data-parser.test.ts new file mode 100644 index 0000000..559f0f7 --- /dev/null +++ b/src/__tests__/data-parser.test.ts @@ -0,0 +1,455 @@ +import { CURRENT_SURVEY_SCHEMA, JsonSurvey } from "../survey/survey-file-schema"; +import { SingleChoiceQuestionItem, DisplayItem, GroupItem } from "../survey/items"; +import { ItemComponentType } from "../survey/components"; +import { ContentType } from "../survey/utils/content"; +import { JsonSurveyCardContent } from "../survey/utils/translations"; +import { Survey } from "../survey/survey"; +import { SurveyItemType } from "../survey/items"; +import { ContextVariableType, ExpressionType, FunctionExpression, ResponseVariableExpression } from "../expressions/expression"; +import { TemplateDefTypes } from "../expressions/template-value"; +import { JsonSurveyDisplayItem, JsonSurveyEndItem, JsonSurveyItemGroup, JsonSurveyQuestionItem } from "../survey/items"; +import { ExpectedValueType } from "../survey"; + + +const surveyCardProps: JsonSurveyCardContent = { + name: { + type: ContentType.CQM, + content: 'Survey Name', + attributions: [] + }, + description: { + type: ContentType.md, + content: 'Survey Description', + }, + typicalDuration: { + type: ContentType.CQM, + content: 'Survey Instructions', + attributions: [] + } +} + +const surveyJson: JsonSurvey = { + $schema: CURRENT_SURVEY_SCHEMA, + surveyItems: { + survey: { + itemType: SurveyItemType.Group, + items: [ + 'survey.group1', + 'survey.pageBreak1', + 'survey.surveyEnd1' + ] + }, + 'survey.group1': { + itemType: SurveyItemType.Group, + items: [ + 'survey.group1.display1' + ] + }, + 'survey.group1.display1': { + itemType: SurveyItemType.Display, + components: [ + { + key: 'comp1', + type: ItemComponentType.Text, + styles: {} + } + ] + }, + 'survey.pageBreak1': { + itemType: SurveyItemType.PageBreak, + }, + 'survey.surveyEnd1': { + itemType: SurveyItemType.SurveyEnd, + }, + }, + translations: { + en: { + surveyCardProps: surveyCardProps, + 'survey.group1.display1': { + 'comp1': { + type: ContentType.CQM, + content: 'Question 1', + attributions: [] + } + } + } + } +} + +const surveyJsonWithConditionsAndValidations: JsonSurvey = { + $schema: CURRENT_SURVEY_SCHEMA, + surveyItems: { + survey: { + itemType: SurveyItemType.Group, + items: [ + 'survey.group1', + 'survey.question1', + 'survey.surveyEnd1' + ] + }, + 'survey.group1': { + itemType: SurveyItemType.Group, + items: [ + 'survey.group1.display1' + ], + displayConditions: { + root: { + type: ExpressionType.Function, + functionName: 'eq', + arguments: [ + { type: ExpressionType.Const, value: 'test' }, + { type: ExpressionType.Const, value: 'value' } + ] + } + } + }, + 'survey.group1.display1': { + itemType: SurveyItemType.Display, + components: [ + { + key: 'comp1', + type: ItemComponentType.Text, + styles: {} + } + ], + displayConditions: { + components: { + 'comp1': { + type: ExpressionType.Function, + functionName: 'gt', + arguments: [ + { type: ExpressionType.Const, value: 10 }, + { type: ExpressionType.Const, value: 5 } + ] + } + } + }, + templateValues: { + 'dynVal1': { + type: TemplateDefTypes.Default, + returnType: ExpectedValueType.String, + expression: { + type: ExpressionType.ContextVariable, + contextType: ContextVariableType.Locale + } + } + } + }, + 'survey.question1': { + itemType: SurveyItemType.SingleChoiceQuestion, + responseConfig: { + key: 'rg', + type: ItemComponentType.SingleChoice, + items: [ + { + key: 'option1', + type: ItemComponentType.ScgMcgOption, + styles: {} + }, + { + key: 'option2', + type: ItemComponentType.ScgMcgOption, + styles: {} + } + ], + styles: {} + }, + validations: { + 'val1': { + type: ExpressionType.ResponseVariable, + variableRef: 'survey.question1...isDefined' + }, + 'val2': { + type: ExpressionType.Function, + functionName: 'not', + arguments: [ + { type: ExpressionType.Function, functionName: 'eq', arguments: [{ type: ExpressionType.Const, value: 'option1' }, { type: ExpressionType.Const, value: 'option2' }] } + ] + } + }, + displayConditions: { + root: { + type: ExpressionType.Function, + functionName: 'and', + arguments: [ + { type: ExpressionType.Function, functionName: 'eq', arguments: [{ type: ExpressionType.Const, value: 'show' }, { type: ExpressionType.Const, value: 'show' }] }, + { type: ExpressionType.Function, functionName: 'gt', arguments: [{ type: ExpressionType.Const, value: 15 }, { type: ExpressionType.Const, value: 10 }] } + ] + }, + components: { + 'rg.option1': { + type: ExpressionType.Function, + functionName: 'lt', + arguments: [ + { type: ExpressionType.Const, value: 5 }, + { type: ExpressionType.Const, value: 10 } + ] + } + } + }, + disabledConditions: { + components: { + 'rg.option2': { + type: ExpressionType.Function, + functionName: 'or', + arguments: [ + { type: ExpressionType.Function, functionName: 'eq', arguments: [{ type: ExpressionType.Const, value: 'disabled' }, { type: ExpressionType.Const, value: 'disabled' }] }, + { type: ExpressionType.Function, functionName: 'gte', arguments: [{ type: ExpressionType.Const, value: 20 }, { type: ExpressionType.Const, value: 15 }] } + ] + } + } + } + }, + 'survey.surveyEnd1': { + itemType: SurveyItemType.SurveyEnd, + }, + }, + translations: { + en: { + surveyCardProps: surveyCardProps, + 'survey.group1.display1': { + 'comp1': { + type: ContentType.CQM, + content: 'Display Component', + attributions: [] + } + }, + 'survey.question1': { + 'title': { + type: ContentType.CQM, + content: 'Single Choice Question', + attributions: [] + }, + 'rg.option1': { + type: ContentType.CQM, + content: 'Option 1', + attributions: [] + }, + 'rg.option2': { + type: ContentType.CQM, + content: 'Option 2', + attributions: [] + } + } + } + } +} + +describe('Data Parsing', () => { + describe('Read Survey from JSON', () => { + test('should throw error if schema is not supported', () => { + const surveyJson = { + $schema: CURRENT_SURVEY_SCHEMA + '1', + surveyItems: { + survey: { + itemType: SurveyItemType.Group, + items: [] + } + } + } + expect(() => Survey.fromJson(surveyJson)).toThrow('Unsupported survey schema'); + }); + + test('should throw error if survey definition is not present', () => { + const surveyJson = { + $schema: CURRENT_SURVEY_SCHEMA, + } + expect(() => Survey.fromJson(surveyJson)).toThrow('surveyItems is required'); + }); + + + test('should parse survey definition', () => { + const survey = Survey.fromJson(surveyJson); + expect(survey.surveyItems).toBeDefined(); + const rootItem = survey.surveyItems['survey'] as GroupItem; + expect(rootItem).toBeDefined(); + expect(rootItem.itemType).toBe(SurveyItemType.Group); + expect(rootItem.items).toBeDefined(); + expect(rootItem.items?.length).toBeGreaterThan(0); + expect(rootItem.items?.[0]).toBe('survey.group1'); + + // Group1 item + const group1Key = rootItem.items?.[0] as string; + const group1Item = survey.surveyItems[group1Key] as GroupItem; + expect(group1Item).toBeDefined(); + expect(group1Item.key.itemKey).toBe('group1'); + expect(group1Item.key.fullKey).toBe(group1Key); + expect(group1Item.itemType).toBe(SurveyItemType.Group); + expect(group1Item.items).toBeDefined(); + expect(group1Item.items?.length).toBeGreaterThan(0); + expect(group1Item.items?.[0]).toBe('survey.group1.display1'); + + // Display item + const display1Key = group1Item.items?.[0] as string; + const displayItem = survey.surveyItems[display1Key] as DisplayItem; + expect(displayItem).toBeDefined(); + expect(displayItem.key.fullKey).toBe('survey.group1.display1'); + expect(displayItem.itemType).toBe(SurveyItemType.Display); + expect(displayItem.components).toBeDefined(); + expect(displayItem.components?.length).toBeGreaterThan(0); + expect(displayItem.components?.[0]?.key.fullKey).toBe('comp1'); + expect(displayItem.components?.[0]?.key.parentItemKey.fullKey).toBe('survey.group1.display1'); + expect(displayItem.components?.[0]?.componentType).toBe(ItemComponentType.Text); + }); + + test('should parse displayConditions, validations, and disabled conditions correctly', () => { + const survey = Survey.fromJson(surveyJsonWithConditionsAndValidations); + expect(survey.surveyItems).toBeDefined(); + + // Test Group with root display condition + const group1Item = survey.surveyItems['survey.group1'] as GroupItem; + expect(group1Item).toBeDefined(); + expect(group1Item.displayConditions).toBeDefined(); + expect(group1Item.displayConditions?.root).toBeDefined(); + expect((group1Item.displayConditions?.root as FunctionExpression)?.functionName).toBe('eq'); + expect((group1Item.displayConditions?.root as FunctionExpression)?.arguments).toHaveLength(2); + expect((group1Item.displayConditions?.root as FunctionExpression)?.arguments?.[0]).toEqual({ type: ExpressionType.Const, value: 'test' }); + expect((group1Item.displayConditions?.root as FunctionExpression)?.arguments?.[1]).toEqual({ type: ExpressionType.Const, value: 'value' }); + + // Test Display item with component display conditions and template values + const displayItem = survey.surveyItems['survey.group1.display1'] as DisplayItem; + expect(displayItem).toBeDefined(); + expect(displayItem.displayConditions).toBeDefined(); + expect(displayItem.displayConditions?.components).toBeDefined(); + expect(displayItem.displayConditions?.components?.['comp1']).toBeDefined(); + expect((displayItem.displayConditions?.components?.['comp1'] as FunctionExpression)?.functionName).toBe('gt'); + expect((displayItem.displayConditions?.components?.['comp1'] as FunctionExpression)?.arguments).toHaveLength(2); + expect((displayItem.displayConditions?.components?.['comp1'] as FunctionExpression)?.arguments?.[0]).toEqual({ type: ExpressionType.Const, value: 10 }); + expect((displayItem.displayConditions?.components?.['comp1'] as FunctionExpression)?.arguments?.[1]).toEqual({ type: ExpressionType.Const, value: 5 }); + + // Test template values + expect(displayItem.templateValues).toBeDefined(); + expect(displayItem.templateValues?.['dynVal1']).toBeDefined(); + expect(displayItem.templateValues?.['dynVal1']?.type).toBe(TemplateDefTypes.Default); + expect(displayItem.templateValues?.['dynVal1']?.returnType).toBe(ExpectedValueType.String); + expect(displayItem.templateValues?.['dynVal1']?.expression).toBeDefined(); + expect(displayItem.templateValues?.['dynVal1']?.expression?.type).toBe(ExpressionType.ContextVariable); + + // Test Single Choice Question with validations, display conditions, and disabled conditions + const questionItem = survey.surveyItems['survey.question1'] as SingleChoiceQuestionItem; + expect(questionItem).toBeDefined(); + expect(questionItem.itemType).toBe(SurveyItemType.SingleChoiceQuestion); + + // Test validations + expect(questionItem.validations).toBeDefined(); + expect(Object.keys(questionItem.validations || {})).toHaveLength(2); + + expect(questionItem.validations?.['val1']).toBeDefined(); + expect(questionItem.validations?.['val1']?.type).toBe(ExpressionType.ResponseVariable); + expect((questionItem.validations?.['val1'] as ResponseVariableExpression)?.variableRef).toBe('survey.question1...isDefined'); + + expect(questionItem.validations?.['val2']).toBeDefined(); + expect(questionItem.validations?.['val2']?.type).toBe(ExpressionType.Function); + expect((questionItem.validations?.['val2'] as FunctionExpression)?.functionName).toBe('not'); + + // Test display conditions on question + expect(questionItem.displayConditions).toBeDefined(); + expect(questionItem.displayConditions?.root).toBeDefined(); + expect((questionItem.displayConditions?.root as FunctionExpression)?.functionName).toBe('and'); + expect((questionItem.displayConditions?.root as FunctionExpression)?.arguments).toHaveLength(2); + + expect(questionItem.displayConditions?.components).toBeDefined(); + expect(questionItem.displayConditions?.components?.['rg.option1']).toBeDefined(); + expect((questionItem.displayConditions?.components?.['rg.option1'] as FunctionExpression)?.functionName).toBe('lt'); + + // Test disabled conditions on question + expect(questionItem.disabledConditions).toBeDefined(); + expect(questionItem.disabledConditions?.components).toBeDefined(); + expect(questionItem.disabledConditions?.components?.['rg.option2']).toBeDefined(); + expect((questionItem.disabledConditions?.components?.['rg.option2'] as FunctionExpression)?.functionName).toBe('or'); + expect((questionItem.disabledConditions?.components?.['rg.option2'] as FunctionExpression)?.arguments).toHaveLength(2); + + // Verify response config was parsed correctly + expect(questionItem.responseConfig).toBeDefined(); + expect(questionItem.responseConfig.componentType).toBe(ItemComponentType.SingleChoice); + expect(questionItem.responseConfig.items).toHaveLength(2); + expect(questionItem.responseConfig.items[0].key.componentKey).toBe('option1'); + expect(questionItem.responseConfig.items[1].key.componentKey).toBe('option2'); + }); + + test('should maintain data integrity in round-trip parsing (JSON -> Survey -> JSON)', () => { + // Parse the survey from JSON + const survey = Survey.fromJson(surveyJsonWithConditionsAndValidations); + + // Convert back to JSON + const exportedJson = survey.toJson(); + + // Verify schema is preserved + expect(exportedJson.$schema).toBe(surveyJsonWithConditionsAndValidations.$schema); + + // Verify all survey items are present + expect(Object.keys(exportedJson.surveyItems)).toEqual(Object.keys(surveyJsonWithConditionsAndValidations.surveyItems)); + + // Test root survey group + const originalRoot = surveyJsonWithConditionsAndValidations.surveyItems.survey as JsonSurveyItemGroup; + const exportedRoot = exportedJson.surveyItems.survey as JsonSurveyItemGroup; + expect(exportedRoot.itemType).toBe(originalRoot.itemType); + expect(exportedRoot.items).toEqual(originalRoot.items); + + // Test group with display conditions + const originalGroup = surveyJsonWithConditionsAndValidations.surveyItems['survey.group1'] as JsonSurveyItemGroup; + const exportedGroup = exportedJson.surveyItems['survey.group1'] as JsonSurveyItemGroup; + expect(exportedGroup.itemType).toBe(originalGroup.itemType); + expect(exportedGroup.items).toEqual(originalGroup.items); + expect(exportedGroup.displayConditions).toEqual(originalGroup.displayConditions); + + // Test display item with display conditions and template values + const originalDisplay = surveyJsonWithConditionsAndValidations.surveyItems['survey.group1.display1'] as JsonSurveyDisplayItem; + const exportedDisplay = exportedJson.surveyItems['survey.group1.display1'] as JsonSurveyDisplayItem; + expect(exportedDisplay.itemType).toBe(originalDisplay.itemType); + expect(exportedDisplay.components).toEqual(originalDisplay.components); + expect(exportedDisplay.displayConditions).toEqual(originalDisplay.displayConditions); + expect(exportedDisplay.templateValues).toEqual(originalDisplay.templateValues); + + // Test single choice question with validations, display conditions, and disabled conditions + const originalQuestion = surveyJsonWithConditionsAndValidations.surveyItems['survey.question1'] as JsonSurveyQuestionItem; + const exportedQuestion = exportedJson.surveyItems['survey.question1'] as JsonSurveyQuestionItem; + expect(exportedQuestion.itemType).toBe(originalQuestion.itemType); + + // Test validations are preserved + expect(exportedQuestion.validations).toEqual(originalQuestion.validations); + + // Test display conditions are preserved + expect(exportedQuestion.displayConditions).toEqual(originalQuestion.displayConditions); + + // Test disabled conditions are preserved + expect(exportedQuestion.disabledConditions).toEqual(originalQuestion.disabledConditions); + + // Test response config structure (allowing for key transformation during parsing) + expect(exportedQuestion.responseConfig.type).toBe(originalQuestion.responseConfig.type); + expect(exportedQuestion.responseConfig.key).toBe(originalQuestion.responseConfig.key); + expect(exportedQuestion.responseConfig.items).toHaveLength(originalQuestion.responseConfig.items!.length); + + // Verify options are preserved (keys might be transformed to full keys) + const exportedOptions = exportedQuestion.responseConfig.items!; + const originalOptions = originalQuestion.responseConfig.items!; + expect(exportedOptions).toHaveLength(2); + expect(exportedOptions[0].type).toBe(originalOptions[0].type); + expect(exportedOptions[1].type).toBe(originalOptions[1].type); + + // Check that keys contain the original option keys (they become full keys after parsing) + expect(exportedOptions[0].key).toContain('option1'); + expect(exportedOptions[1].key).toContain('option2'); + + // Test survey end item + const originalEnd = surveyJsonWithConditionsAndValidations.surveyItems['survey.surveyEnd1'] as JsonSurveyEndItem; + const exportedEnd = exportedJson.surveyItems['survey.surveyEnd1'] as JsonSurveyEndItem; + expect(exportedEnd.itemType).toBe(originalEnd.itemType); + + // Verify translations are preserved + expect(exportedJson.translations).toEqual(surveyJsonWithConditionsAndValidations.translations); + + // Verify that we can parse the exported JSON again (double round-trip) + const secondParsedSurvey = Survey.fromJson(exportedJson); + expect(secondParsedSurvey.surveyItems).toBeDefined(); + expect(Object.keys(secondParsedSurvey.surveyItems)).toEqual(Object.keys(survey.surveyItems)); + + // Verify the complex properties are still intact after double round-trip + const secondQuestionItem = secondParsedSurvey.surveyItems['survey.question1'] as SingleChoiceQuestionItem; + expect(secondQuestionItem.validations).toBeDefined(); + expect(Object.keys(secondQuestionItem.validations || {})).toHaveLength(2); + expect(secondQuestionItem.displayConditions).toBeDefined(); + expect(secondQuestionItem.disabledConditions).toBeDefined(); + }); + }); +}); diff --git a/src/__tests__/engine-rendered-tree.test.ts b/src/__tests__/engine-rendered-tree.test.ts new file mode 100644 index 0000000..808d559 --- /dev/null +++ b/src/__tests__/engine-rendered-tree.test.ts @@ -0,0 +1,604 @@ +import { SurveyEngineCore } from '../engine/engine'; +import { Survey } from '../survey/survey'; +import { GroupItem, DisplayItem, SurveyEndItem, SurveyItemType, SurveyItem, PageBreakItem, SingleChoiceQuestionItem } from '../survey/items'; +import { DisplayComponent, ItemComponentType, ScgMcgOption } from '../survey/components'; + +describe('SurveyEngineCore - ShuffleItems Rendering', () => { + describe('Sequential Rendering (shuffleItems: false/undefined)', () => { + test('should render items in fixed order when shuffleItems is false', () => { + const survey = new Survey('test-survey'); + + // Create multiple items + const displayItem1 = new DisplayItem('test-survey.display1'); + displayItem1.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.display1', 'test-survey.display1') + ]; + + const displayItem2 = new DisplayItem('test-survey.display2'); + displayItem2.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.display2', 'test-survey.display2') + ]; + + const displayItem3 = new DisplayItem('test-survey.display3'); + displayItem3.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.display3', 'test-survey.display3') + ]; + + survey.surveyItems['test-survey.display1'] = displayItem1; + survey.surveyItems['test-survey.display2'] = displayItem2; + survey.surveyItems['test-survey.display3'] = displayItem3; + + // Get the root item and set shuffleItems to false explicitly + const rootItem = survey.surveyItems['test-survey'] as GroupItem; + rootItem.shuffleItems = false; + rootItem.items = ['test-survey.display1', 'test-survey.display2', 'test-survey.display3']; + + const engine = new SurveyEngineCore(survey); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const renderedTree = (engine as any).renderedSurveyTree; + + expect(renderedTree.items).toHaveLength(3); + expect(renderedTree.items[0].key.fullKey).toBe('test-survey.display1'); + expect(renderedTree.items[1].key.fullKey).toBe('test-survey.display2'); + expect(renderedTree.items[2].key.fullKey).toBe('test-survey.display3'); + }); + + test('should render items in fixed order when shuffleItems is undefined', () => { + const survey = new Survey('test-survey'); + + // Create multiple items + const displayItem1 = new DisplayItem('test-survey.display1'); + displayItem1.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.display1', 'test-survey.display1') + ]; + + const displayItem2 = new DisplayItem('test-survey.display2'); + displayItem2.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.display2', 'test-survey.display2') + ]; + + const displayItem3 = new DisplayItem('test-survey.display3'); + displayItem3.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.display3', 'test-survey.display3') + ]; + + survey.surveyItems['test-survey.display1'] = displayItem1; + survey.surveyItems['test-survey.display2'] = displayItem2; + survey.surveyItems['test-survey.display3'] = displayItem3; + + // shuffleItems is undefined by default + const rootItem = survey.surveyItems['test-survey'] as GroupItem; + rootItem.items = ['test-survey.display1', 'test-survey.display2', 'test-survey.display3']; + + const engine = new SurveyEngineCore(survey); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const renderedTree = (engine as any).renderedSurveyTree; + + expect(renderedTree.items).toHaveLength(3); + expect(renderedTree.items[0].key.fullKey).toBe('test-survey.display1'); + expect(renderedTree.items[1].key.fullKey).toBe('test-survey.display2'); + expect(renderedTree.items[2].key.fullKey).toBe('test-survey.display3'); + }); + }); + + describe('Randomized Rendering (shuffleItems: true)', () => { + test('should potentially render items in different order when shuffleItems is true', () => { + const survey = new Survey('test-survey'); + + // Create multiple items + const displayItem1 = new DisplayItem('test-survey.display1'); + displayItem1.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.display1', 'test-survey.display1') + ]; + + const displayItem2 = new DisplayItem('test-survey.display2'); + displayItem2.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.display2', 'test-survey.display2') + ]; + + const displayItem3 = new DisplayItem('test-survey.display3'); + displayItem3.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.display3', 'test-survey.display3') + ]; + + survey.surveyItems['test-survey.display1'] = displayItem1; + survey.surveyItems['test-survey.display2'] = displayItem2; + survey.surveyItems['test-survey.display3'] = displayItem3; + + // Set shuffleItems to true + const rootItem = survey.surveyItems['test-survey'] as GroupItem; + rootItem.shuffleItems = true; + rootItem.items = ['test-survey.display1', 'test-survey.display2', 'test-survey.display3']; + + const engine = new SurveyEngineCore(survey); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const renderedTree = (engine as any).renderedSurveyTree; + + expect(renderedTree.items).toHaveLength(3); + + // All original items should be present + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const renderedKeys = renderedTree.items.map((item: any) => item.key.fullKey); + expect(renderedKeys).toContain('test-survey.display1'); + expect(renderedKeys).toContain('test-survey.display2'); + expect(renderedKeys).toContain('test-survey.display3'); + + // Note: Since shuffling is randomized, we can't test for a specific order + // but we can test that all items are present and the shuffle functionality is used + }); + + test('should test randomization behavior over multiple initializations', () => { + const survey = new Survey('test-survey'); + + // Create multiple items + const displayItem1 = new DisplayItem('test-survey.display1'); + displayItem1.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.display1', 'test-survey.display1') + ]; + + const displayItem2 = new DisplayItem('test-survey.display2'); + displayItem2.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.display2', 'test-survey.display2') + ]; + + const displayItem3 = new DisplayItem('test-survey.display3'); + displayItem3.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.display3', 'test-survey.display3') + ]; + + const displayItem4 = new DisplayItem('test-survey.display4'); + displayItem4.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.display4', 'test-survey.display4') + ]; + + survey.surveyItems['test-survey.display1'] = displayItem1; + survey.surveyItems['test-survey.display2'] = displayItem2; + survey.surveyItems['test-survey.display3'] = displayItem3; + survey.surveyItems['test-survey.display4'] = displayItem4; + + // Set shuffleItems to true + const rootItem = survey.surveyItems['test-survey'] as GroupItem; + rootItem.shuffleItems = true; + rootItem.items = ['test-survey.display1', 'test-survey.display2', 'test-survey.display3', 'test-survey.display4']; + + // Test multiple times to see if order varies (though this is probabilistic) + const orders: string[][] = []; + for (let i = 0; i < 10; i++) { + const engine = new SurveyEngineCore(survey); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const renderedTree = (engine as any).renderedSurveyTree; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const order = renderedTree.items.map((item: any) => item.key.fullKey); + orders.push(order); + } + + // All orders should contain all items + orders.forEach(order => { + expect(order).toHaveLength(4); + expect(order).toContain('test-survey.display1'); + expect(order).toContain('test-survey.display2'); + expect(order).toContain('test-survey.display3'); + expect(order).toContain('test-survey.display4'); + }); + + // At least some variance should occur in ordering (though this is probabilistic) + const uniqueOrders = new Set(orders.map(order => order.join(','))); + expect(uniqueOrders.size).toBeGreaterThan(1); + }); + }); + + describe('Nested Groups with ShuffleItems', () => { + test('should handle nested groups with different shuffle settings', () => { + const survey = new Survey('test-survey'); + + // Create nested structure + const outerGroup = new GroupItem('test-survey.outer'); + outerGroup.shuffleItems = false; // Fixed order for outer group + + const innerGroup1 = new GroupItem('test-survey.outer.inner1'); + innerGroup1.shuffleItems = true; // Shuffled inner group + + const innerGroup2 = new GroupItem('test-survey.outer.inner2'); + innerGroup2.shuffleItems = false; // Fixed order inner group + + // Items for inner1 (will be shuffled) + const display1 = new DisplayItem('test-survey.outer.inner1.display1'); + display1.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.outer.inner1.display1', 'test-survey.outer.inner1.display1') + ]; + + const display2 = new DisplayItem('test-survey.outer.inner1.display2'); + display2.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.outer.inner1.display2', 'test-survey.outer.inner1.display2') + ]; + + // Items for inner2 (fixed order) + const display3 = new DisplayItem('test-survey.outer.inner2.display3'); + display3.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.outer.inner2.display3', 'test-survey.outer.inner2.display3') + ]; + + const display4 = new DisplayItem('test-survey.outer.inner2.display4'); + display4.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.outer.inner2.display4', 'test-survey.outer.inner2.display4') + ]; + + // Set up hierarchy + outerGroup.items = ['test-survey.outer.inner1', 'test-survey.outer.inner2']; + innerGroup1.items = ['test-survey.outer.inner1.display1', 'test-survey.outer.inner1.display2']; + innerGroup2.items = ['test-survey.outer.inner2.display3', 'test-survey.outer.inner2.display4']; + + survey.surveyItems['test-survey.outer'] = outerGroup; + survey.surveyItems['test-survey.outer.inner1'] = innerGroup1; + survey.surveyItems['test-survey.outer.inner2'] = innerGroup2; + survey.surveyItems['test-survey.outer.inner1.display1'] = display1; + survey.surveyItems['test-survey.outer.inner1.display2'] = display2; + survey.surveyItems['test-survey.outer.inner2.display3'] = display3; + survey.surveyItems['test-survey.outer.inner2.display4'] = display4; + + const rootItem = survey.surveyItems['test-survey'] as GroupItem; + rootItem.items = ['test-survey.outer']; + + const engine = new SurveyEngineCore(survey); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const renderedTree = (engine as any).renderedSurveyTree; + + expect(renderedTree.items).toHaveLength(1); + + const outerRenderedGroup = renderedTree.items[0]; + expect(outerRenderedGroup.key.fullKey).toBe('test-survey.outer'); + expect(outerRenderedGroup.items).toHaveLength(2); + + // Outer group should maintain fixed order + expect(outerRenderedGroup.items[0].key.fullKey).toBe('test-survey.outer.inner1'); + expect(outerRenderedGroup.items[1].key.fullKey).toBe('test-survey.outer.inner2'); + + // Inner groups should contain their items + const inner1 = outerRenderedGroup.items[0]; + const inner2 = outerRenderedGroup.items[1]; + + expect(inner1.items).toHaveLength(2); + expect(inner2.items).toHaveLength(2); + + // inner1 has shuffled items (verify all are present) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inner1Keys = inner1.items.map((item: any) => item.key.fullKey); + expect(inner1Keys).toContain('test-survey.outer.inner1.display1'); + expect(inner1Keys).toContain('test-survey.outer.inner1.display2'); + + // inner2 has fixed order + expect(inner2.items[0].key.fullKey).toBe('test-survey.outer.inner2.display3'); + expect(inner2.items[1].key.fullKey).toBe('test-survey.outer.inner2.display4'); + }); + }); + + describe('Survey End Item', () => { + test('should return survey end item when it exists in the survey', () => { + const survey = new Survey('test-survey'); + + // Create a survey end item + const surveyEndItem = new SurveyEndItem('test-survey.end'); + survey.surveyItems['test-survey.end'] = surveyEndItem; + + // Add the survey end item to the root group + const rootItem = survey.surveyItems['test-survey'] as GroupItem; + rootItem.items = ['test-survey.end']; + + const engine = new SurveyEngineCore(survey); + const endItem = engine.surveyEndItem; + + expect(endItem).toBeDefined(); + expect(endItem?.key.fullKey).toBe('test-survey.end'); + expect(endItem?.itemType).toBe(SurveyItemType.SurveyEnd); + }); + + test('should return undefined when no survey end item exists', () => { + const survey = new Survey('test-survey'); + + // Create some other items but no survey end item + const displayItem = new DisplayItem('test-survey.display1'); + displayItem.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', 'test-survey.display1', 'test-survey.display1') + ]; + + survey.surveyItems['test-survey.display1'] = displayItem; + + // Add the display item to the root group + const rootItem = survey.surveyItems['test-survey'] as GroupItem; + rootItem.items = ['test-survey.display1']; + + const engine = new SurveyEngineCore(survey); + const endItem = engine.surveyEndItem; + + expect(endItem).toBeUndefined(); + }); + + test('should return the first survey end item when multiple exist', () => { + const survey = new Survey('test-survey'); + + // Create multiple survey end items + const surveyEndItem1 = new SurveyEndItem('test-survey.end1'); + const surveyEndItem2 = new SurveyEndItem('test-survey.end2'); + + survey.surveyItems['test-survey.end1'] = surveyEndItem1; + survey.surveyItems['test-survey.end2'] = surveyEndItem2; + + // Add both survey end items to the root group + const rootItem = survey.surveyItems['test-survey'] as GroupItem; + rootItem.items = ['test-survey.end1', 'test-survey.end2']; + + const engine = new SurveyEngineCore(survey); + const endItem = engine.surveyEndItem; + + expect(endItem).toBeDefined(); + // Should return the first one found (end1 since it comes first in the items array) + expect(endItem?.key.fullKey).toBe('test-survey.end1'); + expect(endItem?.itemType).toBe(SurveyItemType.SurveyEnd); + }); + + test('should return survey end item from nested groups', () => { + const survey = new Survey('test-survey'); + + // Create a nested group structure + const nestedGroup = new GroupItem('test-survey.nested'); + const surveyEndItem = new SurveyEndItem('test-survey.nested.end'); + + survey.surveyItems['test-survey.nested'] = nestedGroup; + survey.surveyItems['test-survey.nested.end'] = surveyEndItem; + + // Set up the nested structure + nestedGroup.items = ['test-survey.nested.end']; + const rootItem = survey.surveyItems['test-survey'] as GroupItem; + rootItem.items = ['test-survey.nested']; + + const engine = new SurveyEngineCore(survey); + const endItem = engine.surveyEndItem; + + expect(endItem).toBeDefined(); + expect(endItem?.key.fullKey).toBe('test-survey.nested.end'); + expect(endItem?.itemType).toBe(SurveyItemType.SurveyEnd); + }); + }); +}); + +describe('SurveyEngineCore.getSurveyPages', () => { + function makeSurvey(items: SurveyItem[], maxItemsPerPage?: { large: number, small: number }) { + const rootKey = 'test-survey'; + const survey = new Survey(rootKey); + const root = survey.surveyItems[rootKey] as GroupItem; + root.items = items.map(item => item.key.fullKey); + for (const item of items) { + survey.surveyItems[item.key.fullKey] = item; + } + if (maxItemsPerPage) { + survey.maxItemsPerPage = maxItemsPerPage; + } + return survey; + } + + it('returns all items in one page if no maxItemsPerPage and no page breaks', () => { + const items = [ + new DisplayItem('test-survey.q1'), + new DisplayItem('test-survey.q2'), + new DisplayItem('test-survey.q3'), + ]; + const survey = makeSurvey(items); + const engine = new SurveyEngineCore(survey); + const pages = engine.getSurveyPages(); + expect(pages).toHaveLength(1); + expect(pages[0].length).toBe(3); + }); + + it('splits items into pages according to maxItemsPerPage', () => { + const items = [ + new DisplayItem('test-survey.q1'), + new DisplayItem('test-survey.q2'), + new DisplayItem('test-survey.q3'), + new DisplayItem('test-survey.q4'), + new DisplayItem('test-survey.q5'), + ]; + const survey = makeSurvey(items, { large: 2, small: 3 }); + const engine = new SurveyEngineCore(survey); + const pagesLarge = engine.getSurveyPages('large'); + expect(pagesLarge).toHaveLength(3); + expect(pagesLarge[0].length).toBe(2); + expect(pagesLarge[1].length).toBe(2); + expect(pagesLarge[2].length).toBe(1); + const pagesSmall = engine.getSurveyPages('small'); + expect(pagesSmall).toHaveLength(2); + expect(pagesSmall[0].length).toBe(3); + expect(pagesSmall[1].length).toBe(2); + }); + + it('splits pages at page breaks', () => { + const items = [ + new DisplayItem('test-survey.q1'), + new PageBreakItem('test-survey.pb1'), + new DisplayItem('test-survey.q2'), + new PageBreakItem('test-survey.pb2'), + new DisplayItem('test-survey.q3'), + ]; + const survey = makeSurvey(items); + const engine = new SurveyEngineCore(survey); + const pages = engine.getSurveyPages(); + expect(pages).toHaveLength(3); + expect(pages[0].length).toBe(1); + expect(pages[1].length).toBe(1); + expect(pages[2].length).toBe(1); + }); + + it('splits at both page breaks and maxItemsPerPage', () => { + const items = [ + new DisplayItem('test-survey.q1'), + new DisplayItem('test-survey.q2'), + new PageBreakItem('test-survey.pb1'), + new DisplayItem('test-survey.q3'), + new DisplayItem('test-survey.q4'), + new DisplayItem('test-survey.q5'), + new DisplayItem('test-survey.q6'), + new DisplayItem('test-survey.q7'), + new DisplayItem('test-survey.q8'), + ]; + const survey = makeSurvey(items, { large: 4, small: 4 }); + const engine = new SurveyEngineCore(survey); + const pagesLarge = engine.getSurveyPages('large'); + expect(pagesLarge).toHaveLength(3); + expect(pagesLarge[0].length).toBe(2); + expect(pagesLarge[1].length).toBe(4); + expect(pagesLarge[2].length).toBe(2); + }); + + it('returns empty if only page breaks', () => { + const items = [ + new PageBreakItem('test-survey.pb1'), + new PageBreakItem('test-survey.pb2'), + ]; + const survey = makeSurvey(items); + const engine = new SurveyEngineCore(survey); + const pages = engine.getSurveyPages(); + expect(pages).toHaveLength(0); + }); +}); + +describe('Single Choice Question Option Shuffling', () => { + test('should not shuffle options when shuffleItems is false', () => { + const survey = new Survey('test-survey'); + + // Create a single choice question with options + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + + // Add options to the question + const option1 = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + const option2 = new ScgMcgOption('option2', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + const option3 = new ScgMcgOption('option3', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + + questionItem.responseConfig.items = [option1, option2, option3]; + questionItem.responseConfig.shuffleItems = false; + + survey.surveyItems['test-survey.question1'] = questionItem; + + // Add question to root group + const rootItem = survey.surveyItems['test-survey'] as GroupItem; + rootItem.items = ['test-survey.question1']; + + const engine = new SurveyEngineCore(survey); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const renderedTree = (engine as any).renderedSurveyTree; + + expect(renderedTree.items).toHaveLength(1); + + const renderedQuestion = renderedTree.items[0]; + expect(renderedQuestion.key.fullKey).toBe('test-survey.question1'); + expect(renderedQuestion.responseCompOrder).toBeDefined(); + expect(renderedQuestion.responseCompOrder).toHaveLength(3); + + // Options should be in original order when shuffling is disabled + expect(renderedQuestion.responseCompOrder[0]).toBe(option1.key.fullKey); + expect(renderedQuestion.responseCompOrder[1]).toBe(option2.key.fullKey); + expect(renderedQuestion.responseCompOrder[2]).toBe(option3.key.fullKey); + }); + + test('should shuffle options when shuffleItems is true', () => { + const survey = new Survey('test-survey'); + + // Create a single choice question with options + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + + // Add options to the question + const option1 = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + const option2 = new ScgMcgOption('option2', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + const option3 = new ScgMcgOption('option3', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + const option4 = new ScgMcgOption('option4', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + + questionItem.responseConfig.items = [option1, option2, option3, option4]; + questionItem.responseConfig.shuffleItems = true; + + survey.surveyItems['test-survey.question1'] = questionItem; + + // Add question to root group + const rootItem = survey.surveyItems['test-survey'] as GroupItem; + rootItem.items = ['test-survey.question1']; + + // Test multiple times to verify shuffling works + const orders: string[][] = []; + for (let i = 0; i < 10; i++) { + const engine = new SurveyEngineCore(survey); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const renderedTree = (engine as any).renderedSurveyTree; + + expect(renderedTree.items).toHaveLength(1); + + const renderedQuestion = renderedTree.items[0]; + expect(renderedQuestion.key.fullKey).toBe('test-survey.question1'); + expect(renderedQuestion.responseCompOrder).toBeDefined(); + expect(renderedQuestion.responseCompOrder).toHaveLength(4); + + // All original options should be present + expect(renderedQuestion.responseCompOrder).toContain(option1.key.fullKey); + expect(renderedQuestion.responseCompOrder).toContain(option2.key.fullKey); + expect(renderedQuestion.responseCompOrder).toContain(option3.key.fullKey); + expect(renderedQuestion.responseCompOrder).toContain(option4.key.fullKey); + + orders.push([...renderedQuestion.responseCompOrder]); + } + + // At least some variance should occur in ordering (probabilistic test) + const uniqueOrders = new Set(orders.map(order => order.join(','))); + expect(uniqueOrders.size).toBeGreaterThan(1); + }); + + test('should handle empty options array when shuffleItems is true', () => { + const survey = new Survey('test-survey'); + + // Create a single choice question with no options + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + questionItem.responseConfig.items = []; + questionItem.responseConfig.shuffleItems = true; + + survey.surveyItems['test-survey.question1'] = questionItem; + + // Add question to root group + const rootItem = survey.surveyItems['test-survey'] as GroupItem; + rootItem.items = ['test-survey.question1']; + + const engine = new SurveyEngineCore(survey); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const renderedTree = (engine as any).renderedSurveyTree; + + expect(renderedTree.items).toHaveLength(1); + + const renderedQuestion = renderedTree.items[0]; + expect(renderedQuestion.key.fullKey).toBe('test-survey.question1'); + expect(renderedQuestion.responseCompOrder).toBeDefined(); + expect(renderedQuestion.responseCompOrder).toHaveLength(0); + }); + + test('should handle single option when shuffleItems is true', () => { + const survey = new Survey('test-survey'); + + // Create a single choice question with one option + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + + const option1 = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + questionItem.responseConfig.items = [option1]; + questionItem.responseConfig.shuffleItems = true; + + survey.surveyItems['test-survey.question1'] = questionItem; + + // Add question to root group + const rootItem = survey.surveyItems['test-survey'] as GroupItem; + rootItem.items = ['test-survey.question1']; + + const engine = new SurveyEngineCore(survey); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const renderedTree = (engine as any).renderedSurveyTree; + + expect(renderedTree.items).toHaveLength(1); + + const renderedQuestion = renderedTree.items[0]; + expect(renderedQuestion.key.fullKey).toBe('test-survey.question1'); + expect(renderedQuestion.responseCompOrder).toBeDefined(); + expect(renderedQuestion.responseCompOrder).toHaveLength(1); + expect(renderedQuestion.responseCompOrder[0]).toBe(option1.key.fullKey); + }); +}); diff --git a/src/__tests__/engine-response-handling.test.ts b/src/__tests__/engine-response-handling.test.ts new file mode 100644 index 0000000..8d80de6 --- /dev/null +++ b/src/__tests__/engine-response-handling.test.ts @@ -0,0 +1,531 @@ +import { SurveyEngineCore } from '../engine/engine'; +import { Survey } from '../survey/survey'; +import { SurveyItemType, QuestionItem } from '../survey/items'; +import { ResponseItem, JsonSurveyItemResponse } from '../survey/responses/item-response'; +import { ResponseMeta } from '../survey/responses/response-meta'; +import { SurveyEditor } from '../survey-editor'; +import { JsonSurvey, CURRENT_SURVEY_SCHEMA } from '../survey/survey-file-schema'; +import { ItemComponentType } from '../survey/components'; +import { ExpressionType } from '../expressions'; +import { ExpectedValueType } from '../survey/utils/types'; +import { TemplateDefTypes, TemplateValueFormatDate } from '../expressions/template-value'; + +describe('SurveyEngineCore response handling', () => { + function makeSurveyWithQuestions(keys: string[]): Survey { + const rootKey = 'test-survey'; + const survey = new Survey(rootKey); + const editor = new SurveyEditor(survey); + + for (const key of keys) { + editor.initNewItem({ parentKey: rootKey }, SurveyItemType.SingleChoiceQuestion, key); + } + return survey; + } + + function getMetaArray(meta: ResponseMeta | undefined, type: 'responded' | 'displayed'): number[] { + if (!meta) return []; + const json = meta.toJson(); + switch (type) { + case 'responded': return json.responded; + case 'displayed': return json.displayed; + } + } + + describe('Cache initialization', () => { + function createSurveyWithCacheableItems(): Survey { + const surveyJson: JsonSurvey = { + $schema: CURRENT_SURVEY_SCHEMA, + surveyItems: { + 'test-survey': { + itemType: SurveyItemType.Group, + items: [ + 'test-survey.question-with-validations', + 'test-survey.display-with-conditions', + 'test-survey.question-with-disabled-conditions', + 'test-survey.item-with-template-values', + 'test-survey.complex-item' + ] + }, + 'test-survey.question-with-validations': { + itemType: SurveyItemType.SingleChoiceQuestion, + responseConfig: { + key: 'rg', + type: ItemComponentType.SingleChoice, + items: [ + { + key: 'option1', + type: ItemComponentType.ScgMcgOption, + styles: {} + } + ], + styles: {} + }, + validations: { + 'required': { + type: ExpressionType.ResponseVariable, + variableRef: 'test-survey.question-with-validations...isDefined' + }, + 'custom-validation': { + type: ExpressionType.Function, + functionName: 'gt', + arguments: [ + { type: ExpressionType.Const, value: 10 }, + { type: ExpressionType.Const, value: 5 } + ] + } + } + }, + 'test-survey.display-with-conditions': { + itemType: SurveyItemType.Display, + components: [ + { + key: 'comp1', + type: ItemComponentType.Text, + styles: {} + }, + { + key: 'comp2', + type: ItemComponentType.Text, + styles: {} + } + ], + displayConditions: { + root: { + type: ExpressionType.Function, + functionName: 'eq', + arguments: [ + { type: ExpressionType.Const, value: 'show' }, + { type: ExpressionType.Const, value: 'show' } + ] + }, + components: { + 'comp1': { + type: ExpressionType.Function, + functionName: 'gt', + arguments: [ + { type: ExpressionType.Const, value: 10 }, + { type: ExpressionType.Const, value: 5 } + ] + }, + 'comp2': { + type: ExpressionType.Function, + functionName: 'lt', + arguments: [ + { type: ExpressionType.Const, value: 13 }, + { type: ExpressionType.Const, value: 8 } + ] + } + } + } + }, + 'test-survey.question-with-disabled-conditions': { + itemType: SurveyItemType.MultipleChoiceQuestion, + responseConfig: { + key: 'mc', + type: ItemComponentType.MultipleChoice, + items: [ + { + key: 'option1', + type: ItemComponentType.ScgMcgOption, + styles: {} + }, + { + key: 'option2', + type: ItemComponentType.ScgMcgOption, + styles: {} + } + ], + styles: {} + }, + disabledConditions: { + components: { + 'mc.option1': { + type: ExpressionType.Function, + functionName: 'gt', + arguments: [ + { type: ExpressionType.Const, value: 3 }, + { type: ExpressionType.Const, value: 5 } + ] + }, + 'mc.option2': { + type: ExpressionType.Function, + functionName: 'or', + arguments: [ + { type: ExpressionType.Const, value: true }, + { type: ExpressionType.Const, value: false } + ] + } + } + } + }, + 'test-survey.item-with-template-values': { + itemType: SurveyItemType.Display, + components: [ + { + key: 'comp1', + type: ItemComponentType.Text, + styles: {} + } + ], + templateValues: { + 'dynValue1': { + type: TemplateDefTypes.Default, + returnType: ExpectedValueType.String, + expression: { + type: ExpressionType.Const, + value: 'test' + } + }, + 'dynValue2': { + type: TemplateDefTypes.Default, + returnType: ExpectedValueType.Boolean, + expression: { + type: ExpressionType.Function, + functionName: 'gt', + arguments: [ + { type: ExpressionType.Const, value: 5 }, + { type: ExpressionType.Const, value: 3 } + ] + } + }, + 'dynValue3': { + type: TemplateDefTypes.Default, + returnType: ExpectedValueType.Boolean, + expression: { + type: ExpressionType.Const, + value: true + } + }, + 'dynValue4': { + type: TemplateDefTypes.Date2String, + returnType: ExpectedValueType.String, + dateFormat: 'dd/MM/yyyy', + expression: { + type: ExpressionType.Const, + value: new Date('2025-01-01') + } + } + } + }, + 'test-survey.complex-item': { + itemType: SurveyItemType.SingleChoiceQuestion, + responseConfig: { + key: 'rg', + type: ItemComponentType.SingleChoice, + items: [ + { + key: 'option1', + type: ItemComponentType.ScgMcgOption, + styles: {} + }, + { + key: 'option2', + type: ItemComponentType.ScgMcgOption, + styles: {} + } + ], + styles: {} + }, + validations: { + 'validation1': { + type: ExpressionType.Function, + functionName: 'not', + arguments: [ + { type: ExpressionType.Const, value: false } + ] + } + }, + displayConditions: { + root: { + type: ExpressionType.Const, + value: true + }, + components: { + 'rg.option1': { + type: ExpressionType.Function, + functionName: 'and', + arguments: [ + { type: ExpressionType.Const, value: true }, + { type: ExpressionType.Const, value: true } + ] + } + } + }, + disabledConditions: { + components: { + 'rg.option2': { + type: ExpressionType.Const, + value: false + } + } + }, + templateValues: { + 'complexValue': { + type: TemplateDefTypes.Default, + returnType: ExpectedValueType.Boolean, + expression: { + type: ExpressionType.Function, + functionName: 'eq', + arguments: [ + { type: ExpressionType.Const, value: 'test' }, + { type: ExpressionType.Const, value: 'test' } + ] + } + } + } + } + }, + translations: { + en: {} + } + }; + + return Survey.fromJson(surveyJson); + } + + it('should initialize cache with empty objects when no cacheable properties exist', () => { + const survey = makeSurveyWithQuestions(['q1', 'q2']); + const engine = new SurveyEngineCore(survey); + + // Access cache through engine responses to verify initialization + const responses = engine.getResponses(); + expect(responses.length).toBe(2); + + // Cache should be initialized but empty for items without special properties + // This test verifies that the constructor doesn't throw errors during cache initialization + expect(engine.getResponseItem('test-survey.q1')).toBeDefined(); + expect(engine.getResponseItem('test-survey.q2')).toBeDefined(); + + expect(engine.getDisplayConditionValue('test-survey.q1')).toBeUndefined(); + expect(engine.getDisabledConditionValue('test-survey.q1', 'rg.option1')).toBeUndefined(); + expect(engine.getTemplateValue('test-survey.q1', 'dynValue1')).toBeUndefined(); + expect(engine.getValidationValues('test-survey.q1')).toBeUndefined(); + }); + + it('should initialize validations cache for question items with validations', () => { + const survey = createSurveyWithCacheableItems(); + const engine = new SurveyEngineCore(survey); + + // Verify the engine initializes successfully + expect(engine.getResponses().length).toBeGreaterThan(0); + + // Verify items with validations are processed correctly + const questionWithValidations = engine.getResponseItem('test-survey.question-with-validations'); + expect(questionWithValidations).toBeDefined(); + + const complexItem = engine.getResponseItem('test-survey.complex-item'); + expect(complexItem).toBeDefined(); + + const validations = engine.getValidationValues('test-survey.question-with-validations'); + expect(validations).toBeDefined(); + expect(validations).toEqual({ + 'required': false, + 'custom-validation': true + }); + }); + + it('should initialize display conditions cache for items with display conditions', () => { + const survey = createSurveyWithCacheableItems(); + const engine = new SurveyEngineCore(survey); + + // Verify display items with conditions are handled + const displayWithConditions = survey.surveyItems['test-survey.display-with-conditions']; + expect(displayWithConditions.displayConditions).toBeDefined(); + expect(displayWithConditions.displayConditions?.root).toBeDefined(); + expect(displayWithConditions.displayConditions?.components).toBeDefined(); + expect(Object.keys(displayWithConditions.displayConditions?.components || {})).toContain('comp1'); + expect(Object.keys(displayWithConditions.displayConditions?.components || {})).toContain('comp2'); + + // Verify complex item with display conditions + const complexItem = survey.surveyItems['test-survey.complex-item']; + expect(complexItem.displayConditions).toBeDefined(); + expect(complexItem.displayConditions?.root).toBeDefined(); + expect(complexItem.displayConditions?.components).toBeDefined(); + + expect(engine.getDisplayConditionValue('test-survey.display-with-conditions')).toBeTruthy(); + expect(engine.getDisplayConditionValue('test-survey.display-with-conditions', 'comp1')).toBeTruthy(); + expect(engine.getDisplayConditionValue('test-survey.display-with-conditions', 'comp2')).toBeFalsy(); + + + }); + + it('should initialize disabled conditions cache for question items with disabled conditions', () => { + const survey = createSurveyWithCacheableItems(); + const engine = new SurveyEngineCore(survey); + + // Verify question with disabled conditions + const questionWithDisabled = survey.surveyItems['test-survey.question-with-disabled-conditions'] as QuestionItem; + expect(questionWithDisabled.disabledConditions).toBeDefined(); + expect(questionWithDisabled.disabledConditions?.components).toBeDefined(); + expect(Object.keys(questionWithDisabled.disabledConditions?.components || {})).toContain('mc.option1'); + expect(Object.keys(questionWithDisabled.disabledConditions?.components || {})).toContain('mc.option2'); + + // Verify complex item with disabled conditions + const complexItem = survey.surveyItems['test-survey.complex-item'] as QuestionItem; + expect(complexItem.disabledConditions).toBeDefined(); + expect(complexItem.disabledConditions?.components).toBeDefined(); + expect(Object.keys(complexItem.disabledConditions?.components || {})).toContain('rg.option2'); + + expect(engine.getDisabledConditionValue('test-survey.question-with-disabled-conditions', 'mc.option1')).toBeFalsy(); + expect(engine.getDisabledConditionValue('test-survey.question-with-disabled-conditions', 'mc.option2')).toBeTruthy(); + + expect(engine.getDisabledConditionValue('test-survey.complex-item', 'rg.option2')).toBeFalsy(); + }); + + it('should initialize template values cache for items with template values', () => { + const survey = createSurveyWithCacheableItems(); + const engine = new SurveyEngineCore(survey); + + // Verify item with template values + const itemWithTemplates = survey.surveyItems['test-survey.item-with-template-values']; + expect(itemWithTemplates.templateValues).toBeDefined(); + expect(Object.keys(itemWithTemplates.templateValues || {})).toContain('dynValue1'); + expect(Object.keys(itemWithTemplates.templateValues || {})).toContain('dynValue2'); + expect(Object.keys(itemWithTemplates.templateValues || {})).toContain('dynValue3'); + expect(Object.keys(itemWithTemplates.templateValues || {})).toContain('dynValue4'); + + // Verify template values have correct return types + expect(itemWithTemplates.templateValues?.['dynValue1']?.returnType).toBe(ExpectedValueType.String); + expect(itemWithTemplates.templateValues?.['dynValue2']?.returnType).toBe(ExpectedValueType.Boolean); + expect(itemWithTemplates.templateValues?.['dynValue3']?.returnType).toBe(ExpectedValueType.Boolean); + expect(itemWithTemplates.templateValues?.['dynValue4']?.returnType).toBe(ExpectedValueType.String); + expect((itemWithTemplates.templateValues?.['dynValue4'] as TemplateValueFormatDate).dateFormat).toBe('dd/MM/yyyy'); + + // Verify complex item with template values + const complexItem = survey.surveyItems['test-survey.complex-item']; + expect(complexItem.templateValues).toBeDefined(); + expect(Object.keys(complexItem.templateValues || {})).toContain('complexValue'); + expect(complexItem.templateValues?.['complexValue']?.returnType).toBe(ExpectedValueType.Boolean); + + expect(engine.getTemplateValue('test-survey.item-with-template-values', 'dynValue1')?.value).toBe('test'); + expect(engine.getTemplateValue('test-survey.item-with-template-values', 'dynValue2')?.value).toBeTruthy(); + expect(engine.getTemplateValue('test-survey.item-with-template-values', 'dynValue3')?.value).toBeTruthy(); + expect(engine.getTemplateValue('test-survey.item-with-template-values', 'dynValue4')?.value).toBe('01/01/2025'); + + expect(engine.getTemplateValue('test-survey.complex-item', 'complexValue')).toBeTruthy(); + }); + + + it('should not initialize cache entries for items without cacheable properties', () => { + const survey = createSurveyWithCacheableItems(); + const engine = new SurveyEngineCore(survey); + + // The root group item should not have cache entries + const rootItem = survey.surveyItems['test-survey']; + expect(rootItem.itemType).toBe(SurveyItemType.Group); + expect(rootItem.displayConditions).toBeUndefined(); + expect(rootItem.templateValues).toBeUndefined(); + // Group items don't have validations or disabled conditions + + // Engine should still function normally + expect(engine.survey.rootItem.key.fullKey).toBe('test-survey'); + }); + + it('should handle cache initialization with empty validation/condition objects', () => { + const surveyJson: JsonSurvey = { + $schema: CURRENT_SURVEY_SCHEMA, + surveyItems: { + 'test-survey': { + itemType: SurveyItemType.Group, + items: ['test-survey.q1'] + }, + 'test-survey.q1': { + itemType: SurveyItemType.SingleChoiceQuestion, + responseConfig: { + key: 'rg', + type: ItemComponentType.SingleChoice, + items: [ + { + key: 'option1', + type: ItemComponentType.ScgMcgOption, + styles: {} + } + ], + styles: {} + }, + validations: {}, // Empty validations object + displayConditions: {}, // Empty display conditions object + disabledConditions: {}, // Empty disabled conditions object + templateValues: {} // Empty template values object + } + }, + translations: { + en: {} + } + }; + + const survey = Survey.fromJson(surveyJson); + const engine = new SurveyEngineCore(survey); + + // Should not throw errors with empty objects + expect(engine.getResponses().length).toBe(1); + const response = engine.getResponseItem('test-survey.q1'); + expect(response).toBeDefined(); + expect(response?.itemType).toBe(SurveyItemType.SingleChoiceQuestion); + }); + }); + + it('initializes responses for all items', () => { + const survey = makeSurveyWithQuestions(['q1', 'q2']); + const engine = new SurveyEngineCore(survey); + const responses = engine.getResponses(); + expect(responses.length).toBe(2); + expect(responses[0].key.fullKey).toBe('test-survey.q1'); + expect(responses[1].key.fullKey).toBe('test-survey.q2'); + }); + + it('setResponse updates the response and meta', () => { + const survey = makeSurveyWithQuestions(['q1']); + const engine = new SurveyEngineCore(survey); + engine.setResponse('test-survey.q1', new ResponseItem('foo')); + const resp = engine.getResponseItem('test-survey.q1'); + expect(resp?.response?.get()).toBe('foo'); + expect(resp?.meta).toBeDefined(); + expect(getMetaArray(resp?.meta, 'responded').length).toBeGreaterThan(0); + }); + + it('prefills are not used if wrong type provided', () => { + const survey = makeSurveyWithQuestions(['q1', 'q2']); + const prefills: JsonSurveyItemResponse[] = [ + { key: 'test-survey.q1', itemType: SurveyItemType.MultipleChoiceQuestion, response: { value: 'prefilled' } } + ]; + const engine = new SurveyEngineCore(survey, undefined, prefills); + const resp = engine.getResponseItem('test-survey.q1'); + expect(resp?.response).toBeUndefined(); + // q2 should not be prefilled + expect(engine.getResponseItem('test-survey.q2')?.response).toBeUndefined(); + }); + + it('prefills are used if provided', () => { + const survey = makeSurveyWithQuestions(['q1', 'q2']); + const prefills: JsonSurveyItemResponse[] = [ + { key: 'test-survey.q1', itemType: SurveyItemType.SingleChoiceQuestion, response: { value: 'prefilled' } } + ]; + const engine = new SurveyEngineCore(survey, undefined, prefills); + const resp = engine.getResponseItem('test-survey.q1'); + expect(resp?.response?.get()).toBe('prefilled'); + // q2 should not be prefilled + expect(engine.getResponseItem('test-survey.q2')?.response).toBeUndefined(); + }); + + it('setResponse overwrites prefill', () => { + const survey = makeSurveyWithQuestions(['q1']); + const prefills: JsonSurveyItemResponse[] = [ + { key: 'test-survey.q1', itemType: SurveyItemType.Display, response: { value: 'prefilled' } } + ]; + const engine = new SurveyEngineCore(survey, undefined, prefills); + engine.setResponse('test-survey.q1', new ResponseItem('newval')); + const resp = engine.getResponseItem('test-survey.q1'); + expect(resp?.response?.get()).toBe('newval'); + }); + + it('ResponseMeta tracks rendered, displayed, responded', () => { + const survey = makeSurveyWithQuestions(['q1']); + const engine = new SurveyEngineCore(survey); + engine.setResponse('test-survey.q1', new ResponseItem('foo')); + engine.onQuestionDisplayed('test-survey.q1'); + const resp = engine.getResponseItem('test-survey.q1'); + expect(getMetaArray(resp?.meta, 'responded').length).toBeGreaterThan(0); + expect(getMetaArray(resp?.meta, 'displayed').length).toBeGreaterThan(0); + }); +}); diff --git a/src/__tests__/expression-parsing.test.ts b/src/__tests__/expression-parsing.test.ts new file mode 100644 index 0000000..2eaf54a --- /dev/null +++ b/src/__tests__/expression-parsing.test.ts @@ -0,0 +1,504 @@ +import { + Expression, + ExpressionType, + ConstExpression, + ResponseVariableExpression, + ContextVariableExpression, + FunctionExpression, + JsonExpression, + JsonConstExpression, + JsonResponseVariableExpression, + JsonContextVariableExpression, + JsonFunctionExpression, + FunctionExpressionNames, + ContextVariableType +} from '../expressions'; +import { ValueReference } from '../survey/utils/value-reference'; + +describe('Expression JSON Parsing', () => { + describe('ConstExpression', () => { + test('should parse const expression with string value', () => { + const json: JsonConstExpression = { + type: ExpressionType.Const, + value: 'test string' + }; + + const expression = Expression.fromJson(json); + + expect(expression).toBeInstanceOf(ConstExpression); + expect(expression?.type).toBe(ExpressionType.Const); + expect((expression as ConstExpression).value).toBe('test string'); + }); + + test('should parse const expression with number value', () => { + const json: JsonConstExpression = { + type: ExpressionType.Const, + value: 42 + }; + + const expression = Expression.fromJson(json); + + expect(expression).toBeInstanceOf(ConstExpression); + expect(expression?.type).toBe(ExpressionType.Const); + expect((expression as ConstExpression).value).toBe(42); + }); + + test('should parse const expression with boolean value', () => { + const json: JsonConstExpression = { + type: ExpressionType.Const, + value: true + }; + + const expression = Expression.fromJson(json); + + expect(expression).toBeInstanceOf(ConstExpression); + expect(expression?.type).toBe(ExpressionType.Const); + expect((expression as ConstExpression).value).toBe(true); + }); + + test('should parse const expression with array value', () => { + const json: JsonConstExpression = { + type: ExpressionType.Const, + value: ['a', 'b', 'c'] + }; + + const expression = Expression.fromJson(json); + + expect(expression).toBeInstanceOf(ConstExpression); + expect(expression?.type).toBe(ExpressionType.Const); + expect((expression as ConstExpression).value).toEqual(['a', 'b', 'c']); + }); + + test('should parse const expression with undefined value', () => { + const json: JsonConstExpression = { + type: ExpressionType.Const + }; + + const expression = Expression.fromJson(json); + + expect(expression).toBeInstanceOf(ConstExpression); + expect(expression?.type).toBe(ExpressionType.Const); + expect((expression as ConstExpression).value).toBeUndefined(); + }); + + test('should throw error for invalid const expression type', () => { + const json = { + type: ExpressionType.ResponseVariable, + value: 'test' + } as unknown as JsonConstExpression; + + expect(() => ConstExpression.fromJson(json)).toThrow('Invalid expression type: responseVariable'); + }); + }); + + describe('ResponseVariableExpression', () => { + test('should parse response variable expression', () => { + const json: JsonResponseVariableExpression = { + type: ExpressionType.ResponseVariable, + variableRef: 'TS.I1...get' + }; + + const expression = Expression.fromJson(json); + + expect(expression).toBeInstanceOf(ResponseVariableExpression); + expect(expression?.type).toBe(ExpressionType.ResponseVariable); + expect((expression as ResponseVariableExpression).variableRef).toBe('TS.I1...get'); + }); + + test('should throw error for invalid response variable expression type', () => { + const json = { + type: ExpressionType.Const, + variableType: 'string', + variableRef: 'TS.I1...get' + } as unknown as JsonResponseVariableExpression; + + expect(() => ResponseVariableExpression.fromJson(json)).toThrow('Invalid expression type: const'); + }); + }); + + describe('ContextVariableExpression', () => { + test('should parse context variable expression', () => { + const json: JsonContextVariableExpression = { + type: ExpressionType.ContextVariable, + contextType: ContextVariableType.Locale + }; + + const expression = Expression.fromJson(json); + + expect(expression).toBeInstanceOf(ContextVariableExpression); + expect(expression?.type).toBe(ExpressionType.ContextVariable); + }); + + test('should throw error for invalid context variable expression type', () => { + const json = { + type: ExpressionType.Const + } as unknown as JsonContextVariableExpression; + + expect(() => ContextVariableExpression.fromJson(json)).toThrow('Invalid expression type: const'); + }); + }); + + describe('FunctionExpression', () => { + test('should parse function expression with const arguments', () => { + const json: JsonFunctionExpression = { + type: ExpressionType.Function, + functionName: 'gt', + arguments: [ + { type: ExpressionType.Const, value: 5 }, + { type: ExpressionType.Const, value: 3 } + ] + }; + + const expression = Expression.fromJson(json); + + expect(expression).toBeInstanceOf(FunctionExpression); + expect(expression?.type).toBe(ExpressionType.Function); + expect((expression as FunctionExpression).functionName).toBe('gt'); + expect((expression as FunctionExpression).arguments).toHaveLength(2); + expect((expression as FunctionExpression).arguments[0]).toBeInstanceOf(ConstExpression); + expect((expression as FunctionExpression).arguments[1]).toBeInstanceOf(ConstExpression); + }); + + test('should parse function expression with mixed arguments', () => { + const json: JsonFunctionExpression = { + type: ExpressionType.Function, + functionName: 'eq', + arguments: [ + { type: ExpressionType.ResponseVariable, variableRef: 'TS.I1...get' }, + { type: ExpressionType.Const, value: 'expected' } + ] + }; + + const expression = Expression.fromJson(json); + + expect(expression).toBeInstanceOf(FunctionExpression); + expect(expression?.type).toBe(ExpressionType.Function); + expect((expression as FunctionExpression).functionName).toBe('eq'); + expect((expression as FunctionExpression).arguments).toHaveLength(2); + expect((expression as FunctionExpression).arguments[0]).toBeInstanceOf(ResponseVariableExpression); + expect((expression as FunctionExpression).arguments[1]).toBeInstanceOf(ConstExpression); + }); + + test('should parse function expression with nested functions', () => { + const json: JsonFunctionExpression = { + type: ExpressionType.Function, + functionName: 'and', + arguments: [ + { + type: ExpressionType.Function, + functionName: 'gt', + arguments: [ + { type: ExpressionType.ResponseVariable, variableRef: 'TS.I1...get' }, + { type: ExpressionType.Const, value: 0 } + ] + }, + { + type: ExpressionType.Function, + functionName: 'lt', + arguments: [ + { type: ExpressionType.ResponseVariable, variableRef: 'TS.I1...get' }, + { type: ExpressionType.Const, value: 100 } + ] + } + ] + }; + + const expression = Expression.fromJson(json); + + expect(expression).toBeInstanceOf(FunctionExpression); + expect(expression?.type).toBe(ExpressionType.Function); + expect((expression as FunctionExpression).functionName).toBe('and'); + expect((expression as FunctionExpression).arguments).toHaveLength(2); + expect((expression as FunctionExpression).arguments[0]).toBeInstanceOf(FunctionExpression); + expect((expression as FunctionExpression).arguments[1]).toBeInstanceOf(FunctionExpression); + }); + + test('should parse function expression with editor config', () => { + const json: JsonFunctionExpression = { + type: ExpressionType.Function, + functionName: 'str_eq', + arguments: [ + { type: ExpressionType.Const, value: 'test' }, + { type: ExpressionType.Const, value: 'test' } + ], + editorConfig: { + usedTemplate: 'custom-template' + } + }; + + const expression = Expression.fromJson(json); + + expect(expression).toBeInstanceOf(FunctionExpression); + expect((expression as FunctionExpression).editorConfig).toEqual({ + usedTemplate: 'custom-template' + }); + }); + + test('should throw error for invalid function expression type', () => { + const json = { + type: ExpressionType.Const, + functionName: 'str_eq', + arguments: [] + } as unknown as JsonFunctionExpression; + + expect(() => FunctionExpression.fromJson(json)).toThrow('Invalid expression type: const'); + }); + }); + + describe('Expression.fromJson with different types', () => { + test('should parse const expression', () => { + const json: JsonExpression = { + type: ExpressionType.Const, + value: 'test' + }; + + const expression = Expression.fromJson(json); + expect(expression).toBeInstanceOf(ConstExpression); + }); + + test('should parse response variable expression', () => { + const json: JsonExpression = { + type: ExpressionType.ResponseVariable, + variableRef: 'TS.I1...get' + }; + + const expression = Expression.fromJson(json); + expect(expression).toBeInstanceOf(ResponseVariableExpression); + }); + + test('should parse context variable expression', () => { + const json: JsonExpression = { + type: ExpressionType.ContextVariable, + contextType: ContextVariableType.Locale + }; + + const expression = Expression.fromJson(json); + expect(expression).toBeInstanceOf(ContextVariableExpression); + }); + + test('should parse function expression', () => { + const json: JsonExpression = { + type: ExpressionType.Function, + functionName: 'and', + arguments: [] + }; + + const expression = Expression.fromJson(json); + expect(expression).toBeInstanceOf(FunctionExpression); + }); + }); +}); + +describe('Response Variable Reference Extraction', () => { + describe('ConstExpression', () => { + test('should return empty array for const expression', () => { + const expression = new ConstExpression('test'); + expect(expression.responseVariableRefs).toEqual([]); + }); + + test('should return empty array for const expression with undefined value', () => { + const expression = new ConstExpression(); + expect(expression.responseVariableRefs).toEqual([]); + }); + }); + + describe('ResponseVariableExpression', () => { + test('should return single value reference', () => { + const expression = new ResponseVariableExpression('TS.I1...get'); + const refs = expression.responseVariableRefs; + + expect(refs).toHaveLength(1); + expect(refs[0]).toBeInstanceOf(ValueReference); + expect(refs[0].toString()).toBe('TS.I1...get'); + }); + + test('should return value reference with complex path', () => { + const expression = new ResponseVariableExpression('TS.P1.I1...get...SC1'); + const refs = expression.responseVariableRefs; + + expect(refs).toHaveLength(1); + expect(refs[0]).toBeInstanceOf(ValueReference); + expect(refs[0].toString()).toBe('TS.P1.I1...get...SC1'); + }); + }); + + describe('ContextVariableExpression', () => { + test('should return empty array for context variable expression', () => { + const expression = new ContextVariableExpression(ContextVariableType.Locale); + expect(expression.responseVariableRefs).toEqual([]); + }); + }); + + describe('FunctionExpression', () => { + test('should return empty array for function with only const arguments', () => { + const expression = new FunctionExpression(FunctionExpressionNames.gt, [ + new ConstExpression(5), + new ConstExpression(3) + ]); + + expect(expression.responseVariableRefs).toEqual([]); + }); + + test('should return single reference for function with one response variable', () => { + const expression = new FunctionExpression(FunctionExpressionNames.eq, [ + new ResponseVariableExpression('TS.I1...get'), + new ConstExpression('expected') + ]); + + const refs = expression.responseVariableRefs; + expect(refs).toHaveLength(1); + expect(refs[0]).toBeInstanceOf(ValueReference); + expect(refs[0].toString()).toBe('TS.I1...get'); + }); + + test('should return multiple references for function with multiple response variables', () => { + const expression = new FunctionExpression( + FunctionExpressionNames.and, + [ + new ResponseVariableExpression('TS.I1...get'), + new ResponseVariableExpression('TS.I2...get') + ]); + + const refs = expression.responseVariableRefs; + expect(refs).toHaveLength(2); + expect(refs[0]).toBeInstanceOf(ValueReference); + expect(refs[0].toString()).toBe('TS.I1...get'); + expect(refs[1]).toBeInstanceOf(ValueReference); + expect(refs[1].toString()).toBe('TS.I2...get'); + }); + + test('should return references from nested functions', () => { + const nestedFunction = new FunctionExpression(FunctionExpressionNames.gt, [ + new ResponseVariableExpression('TS.I1...get'), + new ConstExpression(0) + ]); + + const expression = new FunctionExpression( + FunctionExpressionNames.and, + [ + nestedFunction, + new ResponseVariableExpression('TS.I2...get') + ]); + + const refs = expression.responseVariableRefs; + expect(refs).toHaveLength(2); + expect(refs[0]).toBeInstanceOf(ValueReference); + expect(refs[0].toString()).toBe('TS.I1...get'); + expect(refs[1]).toBeInstanceOf(ValueReference); + expect(refs[1].toString()).toBe('TS.I2...get'); + }); + + test('should return unique references from complex nested structure', () => { + const innerFunction1 = new FunctionExpression(FunctionExpressionNames.gt, [ + new ResponseVariableExpression('TS.I1...get'), + new ConstExpression(0) + ]); + + const innerFunction2 = new FunctionExpression(FunctionExpressionNames.lt, [ + new ResponseVariableExpression('TS.I1...get'), // Same variable as above + new ConstExpression(100) + ]); + + const expression = new FunctionExpression( + FunctionExpressionNames.and, + [ + innerFunction1, + innerFunction2, + new ResponseVariableExpression('TS.I2...get') + ]); + + const refs = expression.responseVariableRefs; + expect(refs).toHaveLength(2); // TS.I1...get appears twice but should be counted once + expect(refs[0]).toBeInstanceOf(ValueReference); + expect(refs[0].toString()).toBe('TS.I1...get'); + expect(refs[1]).toBeInstanceOf(ValueReference); + expect(refs[1].toString()).toBe('TS.I2...get'); + }); + + test('should handle function with mixed argument types', () => { + const expression = new FunctionExpression( + FunctionExpressionNames.and, + [ + new ResponseVariableExpression('TS.I1...get'), + new ConstExpression('true'), + new ResponseVariableExpression('TS.I2...get'), + new ConstExpression('false') + ] + ); + + const refs = expression.responseVariableRefs; + expect(refs).toHaveLength(2); + expect(refs[0]).toBeInstanceOf(ValueReference); + expect(refs[0].toString()).toBe('TS.I1...get'); + expect(refs[1]).toBeInstanceOf(ValueReference); + expect(refs[1].toString()).toBe('TS.I2...get'); + }); + }); + + describe('Complex Expression Scenarios', () => { + test('should extract all response variable references from complex expression', () => { + // Create a complex expression: (TS.I1...get > 0) AND (TS.I2...get == 'yes') OR (TS.I3...get < 100) + const condition1 = new FunctionExpression(FunctionExpressionNames.gt, [ + new ResponseVariableExpression('TS.I1...get'), + new ConstExpression(0) + ]); + + const condition2 = new FunctionExpression(FunctionExpressionNames.eq, [ + new ResponseVariableExpression('TS.I2...get'), + new ConstExpression('yes') + ]); + + const condition3 = new FunctionExpression(FunctionExpressionNames.lt, [ + new ResponseVariableExpression('TS.I3...get'), + new ConstExpression(100) + ]); + + const andExpression = new FunctionExpression( + FunctionExpressionNames.and, + [condition1, condition2] + ); + const orExpression = new FunctionExpression( + FunctionExpressionNames.or, + [andExpression, condition3] + ); + + const refs = orExpression.responseVariableRefs; + + expect(refs).toHaveLength(3); + const refStrings = refs.map(ref => ref.toString()).sort(); + expect(refStrings).toEqual(['TS.I1...get', 'TS.I2...get', 'TS.I3...get']); + }); + + test('should handle deeply nested expressions', () => { + // Create a deeply nested expression structure + const level4 = new FunctionExpression(FunctionExpressionNames.gt, [ + new ResponseVariableExpression('TS.I4...get'), + new ConstExpression(0) + ]); + + const level3 = new FunctionExpression( + FunctionExpressionNames.and, + [ + new ResponseVariableExpression('TS.I3...get'), + level4 + ]); + + const level2 = new FunctionExpression( + FunctionExpressionNames.or, + [ + new ResponseVariableExpression('TS.I2...get'), + level3 + ]); + + const level1 = new FunctionExpression(FunctionExpressionNames.not, [ + level2 + ]); + + const refs = level1.responseVariableRefs; + + expect(refs).toHaveLength(3); + const refStrings = refs.map(ref => ref.toString()).sort(); + expect(refStrings).toEqual(['TS.I2...get', 'TS.I3...get', 'TS.I4...get']); + }); + }); +}); diff --git a/src/__tests__/expression.test.ts b/src/__tests__/expression.test.ts index 27d3277..f03f375 100644 --- a/src/__tests__/expression.test.ts +++ b/src/__tests__/expression.test.ts @@ -1,1671 +1,1516 @@ -import { add, getUnixTime } from 'date-fns'; -import { Expression, SurveyItemResponse, SurveySingleItem, SurveyContext, ExpressionArg, ExpressionArgDType, SurveyGroupItemResponse } from '../data_types'; -import { ExpressionEval } from '../expression-eval'; - -test('testing undefined expression', () => { - const expEval = new ExpressionEval(); - expect(expEval.eval(undefined)).toBeTruthy(); - expect(expEval.eval({ name: undefined } as any)).toBeTruthy(); -}) - -// ---------- LOGIC OPERATORS ---------------- -test('testing OR expression', () => { - const expEval = new ExpressionEval(); - expect(expEval.eval({ name: 'or', data: [{ dtype: 'num', num: 1 }, { dtype: 'num', num: 0 }] })).toBeTruthy(); - expect(expEval.eval({ name: 'or', data: [{ dtype: 'num', num: 0 }, { dtype: 'num', num: 1 }] })).toBeTruthy(); - expect(expEval.eval({ name: 'or', data: [{ dtype: 'num', num: 1 }, { dtype: 'num', num: 1 }] })).toBeTruthy(); - expect(expEval.eval({ name: 'or', data: [{ dtype: 'num', num: 0 }, { dtype: 'num', num: 0 }] })).toBeFalsy(); -}); +import { ExpressionEvaluator } from "../expressions"; +import { ConstExpression, Expression, ExpressionEditorConfig, ExpressionType, FunctionExpression, FunctionExpressionNames, ResponseVariableExpression } from "../expressions/expression"; +import { ExpectedValueType, ResponseItem, SurveyItemKey, SurveyItemResponse, SurveyItemType, ValueType } from "../survey"; +import { ConstBooleanEditor, ConstDateArrayEditor, ConstDateEditor, ConstNumberArrayEditor, ConstNumberEditor, ConstStringArrayEditor, ConstStringEditor } from "../survey-editor/expression-editor"; +import { const_string, const_string_array, str_list_contains, response_string, const_number, const_boolean, in_range, sum, min, max, ctx_locale, ctx_pflag_is_defined, ctx_pflag_string, ctx_pflag_num, ctx_pflag_date, ctx_custom_value, ctx_custom_expression } from "../survey-editor/expression-editor-generators"; -test('testing AND expression', () => { - const expEval = new ExpressionEval(); - expect(expEval.eval({ name: 'and', data: [{ dtype: 'num', num: 1 }, { dtype: 'num', num: 0 }] })).toBeFalsy(); - expect(expEval.eval({ name: 'and', data: [{ dtype: 'num', num: 0 }, { dtype: 'num', num: 1 }] })).toBeFalsy(); - expect(expEval.eval({ name: 'and', data: [{ dtype: 'num', num: 1 }, { dtype: 'num', num: 1 }] })).toBeTruthy(); - expect(expEval.eval({ name: 'and', data: [{ dtype: 'num', num: 0 }, { dtype: 'num', num: 0 }] })).toBeFalsy(); -}); +describe('expression editor to expression', () => { + describe('Expression Editors', () => { + + describe('ConstStringArrayEditor', () => { + test('should create instance with empty array', () => { + const editor = new ConstStringArrayEditor([]); + + expect(editor.returnType).toBe(ExpectedValueType.StringArray); + expect(editor.values).toEqual([]); + expect(editor.editorConfig).toBeUndefined(); + }); + + test('should create instance with string array values', () => { + const values = ['test1', 'test2', 'test3']; + const editor = new ConstStringArrayEditor(values); + + expect(editor.returnType).toBe(ExpectedValueType.StringArray); + expect(editor.values).toEqual(values); + expect(editor.values).toBe(values); // References the same array + }); + + test('should create instance with editor config', () => { + const config: ExpressionEditorConfig = { usedTemplate: 'test-template' }; + const editor = new ConstStringArrayEditor(['test'], config); + + expect(editor.editorConfig).toEqual(config); + }); + + test('should allow setting values', () => { + const editor = new ConstStringArrayEditor([]); + const newValues = ['new1', 'new2']; + + editor.values = newValues; + + expect(editor.values).toEqual(newValues); + }); + + test('should generate correct ConstExpression', () => { + const values = ['test1', 'test2']; + const config: ExpressionEditorConfig = { usedTemplate: 'test-template' }; + const editor = new ConstStringArrayEditor(values, config); + + const expression = editor.getExpression(); + + expect(expression).toBeInstanceOf(ConstExpression); + expect((expression as ConstExpression).value).toEqual(values); + expect(expression?.editorConfig).toEqual(config); + }); + + test('should support withEditorConfig method', () => { + const editor = new ConstStringArrayEditor(['test']); + const config: ExpressionEditorConfig = { usedTemplate: 'new-template' }; + + const result = editor.withEditorConfig(config); + + expect(result).toBe(editor); // Should return same instance + expect(editor.editorConfig).toEqual(config); + }); + }); + + describe('ConstStringEditor', () => { + test('should create instance with string value', () => { + const value = 'test string'; + const editor = new ConstStringEditor(value); + + expect(editor.returnType).toBe(ExpectedValueType.String); + expect(editor.value).toBe(value); + expect(editor.editorConfig).toBeUndefined(); + }); + + test('should create instance with editor config', () => { + const config: ExpressionEditorConfig = { usedTemplate: 'string-template' }; + const editor = new ConstStringEditor('test', config); + + expect(editor.editorConfig).toEqual(config); + }); + + test('should allow setting value', () => { + const editor = new ConstStringEditor('initial'); + const newValue = 'updated value'; + + editor.value = newValue; + + expect(editor.value).toBe(newValue); + }); + + test('should generate correct ConstExpression', () => { + const value = 'test string'; + const config: ExpressionEditorConfig = { usedTemplate: 'test-template' }; + const editor = new ConstStringEditor(value, config); + + const expression = editor.getExpression(); + + expect(expression).toBeInstanceOf(ConstExpression); + expect((expression as ConstExpression).value).toBe(value); + expect(expression?.editorConfig).toEqual(config); + }); + + test('should handle empty string', () => { + const editor = new ConstStringEditor(''); + + expect(editor.value).toBe(''); + + const expression = editor.getExpression(); + expect((expression as ConstExpression).value).toBe(''); + }); + }); + + describe('ConstNumberEditor', () => { + test('should create instance with number value', () => { + const value = 42.5; + const editor = new ConstNumberEditor(value); + + expect(editor.returnType).toBe(ExpectedValueType.Number); + expect(editor.value).toBe(value); + expect(editor.editorConfig).toBeUndefined(); + }); + + test('should create instance with editor config', () => { + const config: ExpressionEditorConfig = { usedTemplate: 'number-template' }; + const editor = new ConstNumberEditor(123, config); + + expect(editor.editorConfig).toEqual(config); + }); + + test('should allow setting value', () => { + const editor = new ConstNumberEditor(0); + const newValue = 999.99; + + editor.value = newValue; + + expect(editor.value).toBe(newValue); + }); + + test('should generate correct ConstExpression', () => { + const value = -15.7; + const config: ExpressionEditorConfig = { usedTemplate: 'test-template' }; + const editor = new ConstNumberEditor(value, config); + + const expression = editor.getExpression(); + + expect(expression).toBeInstanceOf(ConstExpression); + expect((expression as ConstExpression).value).toBe(value); + expect(expression?.editorConfig).toEqual(config); + }); + + test('should handle zero value', () => { + const editor = new ConstNumberEditor(0); + + expect(editor.value).toBe(0); + + const expression = editor.getExpression(); + expect((expression as ConstExpression).value).toBe(0); + }); + + test('should handle negative values', () => { + const editor = new ConstNumberEditor(-100); + + expect(editor.value).toBe(-100); + + const expression = editor.getExpression(); + expect((expression as ConstExpression).value).toBe(-100); + }); + }); + + describe('ConstBooleanEditor', () => { + test('should create instance with true value', () => { + const editor = new ConstBooleanEditor(true); + + expect(editor.returnType).toBe(ExpectedValueType.Boolean); + expect(editor.value).toBe(true); + expect(editor.editorConfig).toBeUndefined(); + }); + + test('should create instance with false value', () => { + const editor = new ConstBooleanEditor(false); + + expect(editor.returnType).toBe(ExpectedValueType.Boolean); + expect(editor.value).toBe(false); + }); + + test('should create instance with editor config', () => { + const config: ExpressionEditorConfig = { usedTemplate: 'boolean-template' }; + const editor = new ConstBooleanEditor(true, config); + + expect(editor.editorConfig).toEqual(config); + }); + + test('should allow setting value', () => { + const editor = new ConstBooleanEditor(true); + + editor.value = false; + expect(editor.value).toBe(false); -test('testing NOT expression', () => { - const trueExp: Expression = { name: 'and', data: [{ dtype: 'num', num: 1 }, { dtype: 'num', num: 1 }] } - const falseExp: Expression = { name: 'and', data: [{ dtype: 'num', num: 0 }, { dtype: 'num', num: 1 }] } + editor.value = true; + expect(editor.value).toBe(true); + }); - const expEval = new ExpressionEval(); - expect(expEval.eval({ name: 'not', data: [{ dtype: 'exp', exp: trueExp }] })).toBeFalsy(); - expect(expEval.eval({ name: 'not', data: [{ dtype: 'exp', exp: falseExp }] })).toBeTruthy(); + test('should generate correct ConstExpression for true', () => { + const config: ExpressionEditorConfig = { usedTemplate: 'test-template' }; + const editor = new ConstBooleanEditor(true, config); + + const expression = editor.getExpression(); + + expect(expression).toBeInstanceOf(ConstExpression); + expect((expression as ConstExpression).value).toBe(true); + expect(expression?.editorConfig).toEqual(config); + }); + + test('should generate correct ConstExpression for false', () => { + const editor = new ConstBooleanEditor(false); + + const expression = editor.getExpression(); + + expect(expression).toBeInstanceOf(ConstExpression); + expect((expression as ConstExpression).value).toBe(false); + }); + }); + + describe('ConstDateEditor', () => { + test('should create instance with Date value', () => { + const date = new Date('2024-01-15T10:30:00Z'); + const editor = new ConstDateEditor(date); + + expect(editor.returnType).toBe(ExpectedValueType.Date); + expect(editor.value).toBe(date); + expect(editor.editorConfig).toBeUndefined(); + }); + + test('should create instance with editor config', () => { + const config: ExpressionEditorConfig = { usedTemplate: 'date-template' }; + const date = new Date(); + const editor = new ConstDateEditor(date, config); + + expect(editor.editorConfig).toEqual(config); + }); + + test('should allow setting value', () => { + const initialDate = new Date('2024-01-01'); + const newDate = new Date('2024-12-31'); + const editor = new ConstDateEditor(initialDate); + + editor.value = newDate; + + expect(editor.value).toBe(newDate); + }); + + test('should generate correct ConstExpression', () => { + const date = new Date('2024-06-15T14:30:00Z'); + const config: ExpressionEditorConfig = { usedTemplate: 'test-template' }; + const editor = new ConstDateEditor(date, config); + + const expression = editor.getExpression(); + + expect(expression).toBeInstanceOf(ConstExpression); + expect((expression as ConstExpression).value).toBe(date); + expect(expression?.editorConfig).toEqual(config); + }); + + test('should handle current date', () => { + const now = new Date(); + const editor = new ConstDateEditor(now); + + expect(editor.value).toBe(now); + + const expression = editor.getExpression(); + expect((expression as ConstExpression).value).toBe(now); + }); + }); + + describe('ConstNumberArrayEditor', () => { + test('should create instance with empty array', () => { + const editor = new ConstNumberArrayEditor([]); + + expect(editor.returnType).toBe(ExpectedValueType.NumberArray); + expect(editor.values).toEqual([]); + expect(editor.editorConfig).toBeUndefined(); + }); + + test('should create instance with number array values', () => { + const values = [1, 2.5, -3, 0, 999.99]; + const editor = new ConstNumberArrayEditor(values); + + expect(editor.returnType).toBe(ExpectedValueType.NumberArray); + expect(editor.values).toEqual(values); + expect(editor.values).toBe(values); // References the same array + }); + + test('should create instance with editor config', () => { + const config: ExpressionEditorConfig = { usedTemplate: 'number-array-template' }; + const editor = new ConstNumberArrayEditor([1, 2, 3], config); + + expect(editor.editorConfig).toEqual(config); + }); + + test('should allow setting values', () => { + const editor = new ConstNumberArrayEditor([]); + const newValues = [10, 20, 30]; + + editor.values = newValues; + + expect(editor.values).toEqual(newValues); + }); + + test('should generate correct ConstExpression', () => { + const values = [1.1, 2.2, 3.3]; + const config: ExpressionEditorConfig = { usedTemplate: 'test-template' }; + const editor = new ConstNumberArrayEditor(values, config); + + const expression = editor.getExpression(); + + expect(expression).toBeInstanceOf(ConstExpression); + expect((expression as ConstExpression).value).toEqual(values); + expect(expression?.editorConfig).toEqual(config); + }); + + test('should handle array with mixed positive and negative numbers', () => { + const values = [-100, 0, 100, -1.5, 1.5]; + const editor = new ConstNumberArrayEditor(values); + + expect(editor.values).toEqual(values); + + const expression = editor.getExpression(); + expect((expression as ConstExpression).value).toEqual(values); + }); + }); + + describe('ConstDateArrayEditor', () => { + test('should create instance with empty array', () => { + const editor = new ConstDateArrayEditor([]); + + expect(editor.returnType).toBe(ExpectedValueType.DateArray); + expect(editor.values).toEqual([]); + expect(editor.editorConfig).toBeUndefined(); + }); + + test('should create instance with date array values', () => { + const values = [ + new Date('2024-01-01'), + new Date('2024-06-15'), + new Date('2024-12-31') + ]; + const editor = new ConstDateArrayEditor(values); + + expect(editor.returnType).toBe(ExpectedValueType.DateArray); + expect(editor.values).toEqual(values); + expect(editor.values).toBe(values); // References the same array + }); + + test('should create instance with editor config', () => { + const config: ExpressionEditorConfig = { usedTemplate: 'date-array-template' }; + const dates = [new Date()]; + const editor = new ConstDateArrayEditor(dates, config); + + expect(editor.editorConfig).toEqual(config); + }); + + test('should allow setting values', () => { + const editor = new ConstDateArrayEditor([]); + const newValues = [ + new Date('2023-01-01'), + new Date('2023-12-31') + ]; + + editor.values = newValues; + + expect(editor.values).toEqual(newValues); + }); + + test('should generate correct ConstExpression', () => { + const values = [ + new Date('2024-03-15T09:00:00Z'), + new Date('2024-03-16T10:00:00Z') + ]; + const config: ExpressionEditorConfig = { usedTemplate: 'test-template' }; + const editor = new ConstDateArrayEditor(values, config); + + const expression = editor.getExpression(); + + expect(expression).toBeInstanceOf(ConstExpression); + expect((expression as ConstExpression).value).toEqual(values); + expect(expression?.editorConfig).toEqual(config); + }); + + test('should handle array with single date', () => { + const date = new Date('2024-07-04T12:00:00Z'); + const editor = new ConstDateArrayEditor([date]); + + expect(editor.values).toEqual([date]); + + const expression = editor.getExpression(); + expect((expression as ConstExpression).value).toEqual([date]); + }); + }); + + describe('ExpressionEditor base class functionality', () => { + test('should support withEditorConfig method', () => { + const editor = new ConstStringEditor('test'); + const config: ExpressionEditorConfig = { usedTemplate: 'test-template' }; + + const result = editor.withEditorConfig(config); + + expect(result).toBe(editor); // Should return same instance for chaining + expect(editor.editorConfig).toEqual(config); + }); + + test('should support method chaining with withEditorConfig', () => { + const config: ExpressionEditorConfig = { usedTemplate: 'chained-template' }; + + const editor = new ConstNumberEditor(42) + .withEditorConfig(config); + + expect(editor.editorConfig).toEqual(config); + expect((editor as ConstNumberEditor).value).toBe(42); + }); + + test('should allow updating editor config', () => { + const editor = new ConstBooleanEditor(true); + const config1: ExpressionEditorConfig = { usedTemplate: 'config-1' }; + const config2: ExpressionEditorConfig = { usedTemplate: 'config-2' }; + + editor.withEditorConfig(config1); + expect(editor.editorConfig).toEqual(config1); + + editor.withEditorConfig(config2); + expect(editor.editorConfig).toEqual(config2); + }); + }); + }); + + describe('simple expressions', () => { + it('create simple const string array expression', () => { + const editor = const_string_array('test', 'test2'); + + const expression = editor.getExpression(); + expect(expression).toBeInstanceOf(ConstExpression); + expect(expression?.type).toBe(ExpressionType.Const); + expect((expression as ConstExpression).value).toEqual(['test', 'test2']); + }); + + it('create empty const string array expression', () => { + const editor = const_string_array(); + + const expression = editor.getExpression(); + expect(expression).toBeInstanceOf(ConstExpression); + expect(expression?.type).toBe(ExpressionType.Const); + expect((expression as ConstExpression).value).toEqual([]); + }); + }); + + describe('function expressions', () => { + it('create simple list contains expression', () => { + const editor = str_list_contains(const_string_array('test', 'test2'), const_string('test3')); + + const expression = editor.getExpression(); + expect(expression).toBeInstanceOf(FunctionExpression); + expect(expression?.type).toBe(ExpressionType.Function); + expect((expression as FunctionExpression).functionName).toBe(FunctionExpressionNames.list_contains); + expect((expression as FunctionExpression).arguments).toHaveLength(2); + expect((expression as FunctionExpression).arguments[0]).toBeInstanceOf(ConstExpression); + expect((expression as FunctionExpression).arguments[0]?.type).toBe(ExpressionType.Const); + expect(((expression as FunctionExpression).arguments[0] as ConstExpression)?.value).toEqual(['test', 'test2']); + expect(((expression as FunctionExpression).arguments[1] as ConstExpression)?.value).toEqual('test3'); + }); + }); + + describe('response variable expressions', () => { + it('create simple response string expression', () => { + const editor = response_string('survey.test...get'); + + const expression = editor.getExpression(); + expect(expression).toBeInstanceOf(ResponseVariableExpression); + expect(expression?.type).toBe(ExpressionType.ResponseVariable); + expect((expression as ResponseVariableExpression).variableRef).toEqual('survey.test...get'); + expect((expression as ResponseVariableExpression).responseVariableRefs).toHaveLength(1); + expect((expression as ResponseVariableExpression).responseVariableRefs[0].toString()).toEqual('survey.test...get'); + }); + }); }); -// ---------- COMPARISONS ---------------- -test('testing EQ expression', () => { - const expEval = new ExpressionEval(); - // numbers - expect(expEval.eval({ name: 'eq', data: [{ dtype: 'num', num: 1 }, { dtype: 'num', num: 0 }] })).toBeFalsy(); - expect(expEval.eval({ name: 'eq', data: [{ dtype: 'num', num: 1 }, { dtype: 'num', num: 1 }] })).toBeTruthy(); - - // strings - expect(expEval.eval({ name: 'eq', data: [{ dtype: 'str', str: "test1" }, { dtype: 'str', str: "test2" }] })).toBeFalsy(); - expect(expEval.eval({ name: 'eq', data: [{ str: "test1" }, { str: "test1" }] })).toBeTruthy(); -}) - -test('testing LT expression', () => { - const expEval = new ExpressionEval(); - // numbers - expect(expEval.eval({ name: 'lt', data: [{ dtype: 'num', num: 3 }, { dtype: 'num', num: 2 }] })).toBeFalsy(); - expect(expEval.eval({ name: 'lt', data: [{ dtype: 'num', num: 2 }, { dtype: 'num', num: 2 }] })).toBeFalsy(); - expect(expEval.eval({ name: 'lt', data: [{ dtype: 'num', num: 1 }, { dtype: 'num', num: 2 }] })).toBeTruthy(); - - // strings - expect(expEval.eval({ name: 'lt', data: [{ dtype: 'str', str: "test3" }, { dtype: 'str', str: "test2" }] })).toBeFalsy(); - expect(expEval.eval({ name: 'lt', data: [{ dtype: 'str', str: "test2" }, { dtype: 'str', str: "test2" }] })).toBeFalsy(); - expect(expEval.eval({ name: 'lt', data: [{ dtype: 'str', str: "test1" }, { dtype: 'str', str: "test2" }] })).toBeTruthy(); -}) - -test('testing LTE expression', () => { - const expEval = new ExpressionEval(); - // numbers - expect(expEval.eval({ name: 'lte', data: [{ dtype: 'num', num: 3 }, { dtype: 'num', num: 2 }] })).toBeFalsy(); - expect(expEval.eval({ name: 'lte', data: [{ dtype: 'num', num: 2 }, { dtype: 'num', num: 2 }] })).toBeTruthy(); - expect(expEval.eval({ name: 'lte', data: [{ dtype: 'num', num: 1 }, { dtype: 'num', num: 2 }] })).toBeTruthy(); - - // strings - expect(expEval.eval({ name: 'lte', data: [{ dtype: 'str', str: "test3" }, { dtype: 'str', str: "test2" }] })).toBeFalsy(); - expect(expEval.eval({ name: 'lte', data: [{ dtype: 'str', str: "test2" }, { dtype: 'str', str: "test2" }] })).toBeTruthy(); - expect(expEval.eval({ name: 'lte', data: [{ dtype: 'str', str: "test1" }, { dtype: 'str', str: "test2" }] })).toBeTruthy(); -}) - -test('testing GT expression', () => { - const expEval = new ExpressionEval(); - // numbers - expect(expEval.eval({ name: 'gt', data: [{ dtype: 'num', num: 3 }, { dtype: 'num', num: 2 }] })).toBeTruthy(); - expect(expEval.eval({ name: 'gt', data: [{ dtype: 'num', num: 2 }, { dtype: 'num', num: 2 }] })).toBeFalsy(); - expect(expEval.eval({ name: 'gt', data: [{ dtype: 'num', num: 1 }, { dtype: 'num', num: 2 }] })).toBeFalsy(); - - // strings - expect(expEval.eval({ name: 'gt', data: [{ dtype: 'str', str: "test3" }, { dtype: 'str', str: "test2" }] })).toBeTruthy(); - expect(expEval.eval({ name: 'gt', data: [{ dtype: 'str', str: "test2" }, { dtype: 'str', str: "test2" }] })).toBeFalsy(); - expect(expEval.eval({ name: 'gt', data: [{ dtype: 'str', str: "test1" }, { dtype: 'str', str: "test2" }] })).toBeFalsy(); -}) - -test('testing GTE expression', () => { - const expEval = new ExpressionEval(); - // numbers - expect(expEval.eval({ name: 'gte', data: [{ dtype: 'num', num: 3 }, { dtype: 'num', num: 2 }] })).toBeTruthy(); - expect(expEval.eval({ name: 'gte', data: [{ dtype: 'num', num: 2 }, { dtype: 'num', num: 2 }] })).toBeTruthy(); - expect(expEval.eval({ name: 'gte', data: [{ dtype: 'num', num: 1 }, { dtype: 'num', num: 2 }] })).toBeFalsy(); - - // strings - expect(expEval.eval({ name: 'gte', data: [{ dtype: 'str', str: "test3" }, { dtype: 'str', str: "test2" }] })).toBeTruthy(); - expect(expEval.eval({ name: 'gte', data: [{ dtype: 'str', str: "test2" }, { dtype: 'str', str: "test2" }] })).toBeTruthy(); - expect(expEval.eval({ name: 'gte', data: [{ dtype: 'str', str: "test1" }, { dtype: 'str', str: "test2" }] })).toBeFalsy(); -}) - -test('testing expression: isDefined', () => { - const expEval = new ExpressionEval(); - const testSurveyResponses: SurveyItemResponse = { - key: 'TS', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - items: [ - { - key: 'TS.I1', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - response: { - key: 'R1', - } - } - ] - } - - - expect(expEval.eval({ - name: 'isDefined', data: [ - { - dtype: 'exp', exp: { - name: 'getObjByHierarchicalKey', - data: [ - { dtype: 'exp', exp: { name: 'getResponses' } }, - { dtype: 'str', str: 'TS.I1' } - ] - } - } - ] - }, undefined, undefined, testSurveyResponses)).toBeTruthy(); - - expect(expEval.eval({ - name: 'isDefined', data: [ - { - dtype: 'exp', exp: { - name: 'getObjByHierarchicalKey', - data: [ - { dtype: 'exp', exp: { name: 'getResponses' } }, - { dtype: 'str', str: 'TS.IWRONG' } - ] - } +describe('expression evaluator', () => { + let expression: Expression; + + beforeEach(() => { + const editor = str_list_contains( + const_string_array('option1', 'option2'), + response_string('survey.question1...get') + ); + expression = editor.getExpression() as Expression; + }); + + it('if no response is provided, the expression should be false', () => { + const expEval = new ExpressionEvaluator(); + expect(expEval.eval(expression)).toBeFalsy(); + }); + + it('if the response is provided, but the question is not answered, the expression should be false', () => { + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en' } - ] - }, undefined, undefined, testSurveyResponses)).toBeFalsy(); -}) - - -test('testing expression: parseValueAsNum', () => { - const expEval = new ExpressionEval(); - - expect(expEval.eval({ - name: 'parseValueAsNum', data: [ - { - dtype: 'exp', exp: { - name: "getAttribute", - data: [ - { - dtype: 'exp', exp: { - name: "getAttribute", - data: [ - { dtype: 'exp', exp: { name: 'getContext' } }, - { dtype: 'str', str: 'participantFlags' } - ], - } - }, - { dtype: 'str', str: 'test' } - ] - } + }); + expect(expEval.eval(expression)).toBeFalsy(); + }); + + it('if the response is provided, and the question is answered, the expression should be true', () => { + const expEval = new ExpressionEvaluator({ + responses: { + 'survey.question1': new SurveyItemResponse({ + key: SurveyItemKey.fromFullKey('survey.question1'), + itemType: SurveyItemType.SingleChoiceQuestion, + }, new ResponseItem('option1')) }, - ] - }, undefined, { - participantFlags: { - test: '2' - } - }, undefined)).toEqual(2); - - expect(expEval.eval({ - name: 'parseValueAsNum', data: [ - { - dtype: 'exp', exp: { - name: "getAttribute", - data: [ - { - dtype: 'exp', exp: { - name: "getAttribute", - data: [ - { dtype: 'exp', exp: { name: 'getContext' } }, - { dtype: 'str', str: 'participantFlags' } - ], - } - }, - { dtype: 'str', str: 'wrong' } - ] - } - }, - ] - }, undefined, { - participantFlags: { - test: '2' - } - }, undefined)).toBeUndefined(); + surveyContext: { + locale: 'en' + } + }); + expect(expEval.eval(expression)).toBeTruthy(); + }); + + describe('in_range function evaluation', () => { + it('should return true for value in range (inclusive)', () => { + const editor = in_range(const_number(5), const_number(1), const_number(10), const_boolean(true)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(expression)).toBeTruthy(); + }); + + it('should return true for value at boundary (inclusive)', () => { + const editor1 = in_range(const_number(1), const_number(1), const_number(10), const_boolean(true)); + const editor2 = in_range(const_number(10), const_number(1), const_number(10), const_boolean(true)); + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(editor1.getExpression() as Expression)).toBeTruthy(); + expect(expEval.eval(editor2.getExpression() as Expression)).toBeTruthy(); + }); + + it('should return false for value at boundary (exclusive)', () => { + const editor1 = in_range(const_number(1), const_number(1), const_number(10), const_boolean(false)); + const editor2 = in_range(const_number(10), const_number(1), const_number(10), const_boolean(false)); + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(editor1.getExpression() as Expression)).toBeFalsy(); + expect(expEval.eval(editor2.getExpression() as Expression)).toBeFalsy(); + }); + + it('should return true for value in range (exclusive)', () => { + const editor = in_range(const_number(5), const_number(1), const_number(10), const_boolean(false)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(expression)).toBeTruthy(); + }); + + it('should return false for value outside range', () => { + const editor1 = in_range(const_number(0), const_number(1), const_number(10), const_boolean(true)); + const editor2 = in_range(const_number(11), const_number(1), const_number(10), const_boolean(true)); + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(editor1.getExpression() as Expression)).toBeFalsy(); + expect(expEval.eval(editor2.getExpression() as Expression)).toBeFalsy(); + }); + + it('should return false for undefined values', () => { + const editor = in_range(response_string('survey.nonexistent...get'), const_number(1), const_number(10), const_boolean(true)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(expression)).toBeFalsy(); + }); + + it('should throw error for wrong argument count', () => { + const expEval = new ExpressionEvaluator(); + const functionExpr = new FunctionExpression(FunctionExpressionNames.in_range, [ + new ConstExpression(5), + new ConstExpression(1) + ]); + + expect(() => expEval.eval(functionExpr)).toThrow('In range function expects 4 arguments, got 2'); + }); + }); + + describe('sum function evaluation', () => { + it('should return sum of positive numbers', () => { + const editor = sum(const_number(1), const_number(2), const_number(3)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(expression)).toBe(6); + }); + + it('should return sum including negative numbers', () => { + const editor = sum(const_number(10), const_number(-3), const_number(2)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(expression)).toBe(9); + }); + + it('should return sum including decimal numbers', () => { + const editor = sum(const_number(1.5), const_number(2.3), const_number(0.2)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(expression)).toBeCloseTo(4.0); + }); + + it('should handle single argument', () => { + const editor = sum(const_number(42)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(expression)).toBe(42); + }); + + it('should skip undefined values', () => { + const editor = sum(const_number(1), response_string('survey.nonexistent...get'), const_number(3)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(expression)).toBe(4); + }); + + it('should throw error for non-numeric values', () => { + const editor = sum(const_number(1), const_string('invalid')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(() => expEval.eval(expression)).toThrow('Sum function expects all arguments to be numbers, got string'); + }); + }); + + describe('min function evaluation', () => { + it('should return minimum of positive numbers', () => { + const editor = min(const_number(5), const_number(2), const_number(8)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(expression)).toBe(2); + }); + + it('should return minimum including negative numbers', () => { + const editor = min(const_number(10), const_number(-3), const_number(2)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(expression)).toBe(-3); + }); + + it('should return minimum including decimal numbers', () => { + const editor = min(const_number(1.5), const_number(2.3), const_number(0.2)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(expression)).toBe(0.2); + }); + + it('should handle single argument', () => { + const editor = min(const_number(42)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(expression)).toBe(42); + }); + + it('should skip undefined values', () => { + const editor = min(const_number(5), response_string('survey.nonexistent...get'), const_number(3)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(expression)).toBe(3); + }); + + it('should throw error for non-numeric values', () => { + const editor = min(const_number(1), const_string('invalid')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(() => expEval.eval(expression)).toThrow('Min function expects all arguments to be numbers, got string'); + }); + + it('should throw error for no arguments', () => { + const expEval = new ExpressionEvaluator(); + const functionExpr = new FunctionExpression(FunctionExpressionNames.min, []); + + expect(() => expEval.eval(functionExpr)).toThrow('Min function expects at least 1 argument, got 0'); + }); + }); + + describe('max function evaluation', () => { + it('should return maximum of positive numbers', () => { + const editor = max(const_number(5), const_number(2), const_number(8)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); -}); + expect(expEval.eval(expression)).toBe(8); + }); + + it('should return maximum including negative numbers', () => { + const editor = max(const_number(-10), const_number(-3), const_number(-5)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); -test('testing expression: getResponseValueAsNum', () => { - const expEval = new ExpressionEval(); - const testSurveyResponses: SurveyItemResponse = { - key: 'TS', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - items: [ - { - key: 'TS.I1', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - response: { - key: 'R1', - items: [ - { key: 'V1', value: 'not a number' }, - { key: 'V2', value: '123.23' }, - { key: 'V3' } - ] - } - } - ] - } - - - - expect(expEval.eval({ - name: 'getResponseValueAsNum', data: [ - { dtype: 'str', str: 'TS.wrong' }, - { dtype: 'str', str: 'R1.V2' }, - ] - }, undefined, undefined, testSurveyResponses)).toBeUndefined(); - - expect(expEval.eval({ - name: 'getResponseValueAsNum', data: [ - { dtype: 'str', str: 'TS.I1' }, - { dtype: 'str', str: 'R1.Vwrong' }, - ] - }, undefined, undefined, testSurveyResponses)).toBeUndefined(); - - - expect(expEval.eval({ - name: 'getResponseValueAsNum', data: [ - { dtype: 'str', str: 'TS.I1' }, - { dtype: 'str', str: 'R1.V3' }, - ] - }, undefined, undefined, testSurveyResponses)).toBeUndefined(); - - expect(expEval.eval({ - name: 'getResponseValueAsNum', data: [ - { dtype: 'str', str: 'TS.I1' }, - { dtype: 'str', str: 'R1.V1' }, - ] - }, undefined, undefined, testSurveyResponses)).toBeNaN(); - - expect(expEval.eval({ - name: 'getResponseValueAsNum', data: [ - { dtype: 'str', str: 'TS.I1' }, - { dtype: 'str', str: 'R1.V2' }, - ] - }, undefined, undefined, testSurveyResponses)).toEqual(123.23); -}); + expect(expEval.eval(expression)).toBe(-3); + }); + + it('should return maximum including decimal numbers', () => { + const editor = max(const_number(1.5), const_number(2.3), const_number(0.2)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); -test('testing expression: getResponseValueAsStr', () => { - const expEval = new ExpressionEval(); - const testSurveyResponses: SurveyItemResponse = { - key: 'TS', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - items: [ - { - key: 'TS.I1', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - response: { - key: 'R1', - items: [ - { key: 'V1' }, - { key: 'V2', value: 'something' } - ] - } - } - ] - } - - expect(expEval.eval({ - name: 'getResponseValueAsStr', data: [ - { dtype: 'str', str: 'TS.wrong' }, - { dtype: 'str', str: 'R1.V2' }, - ] - }, undefined, undefined, testSurveyResponses)).toBeUndefined(); - - expect(expEval.eval({ - name: 'getResponseValueAsStr', data: [ - { dtype: 'str', str: 'TS.I1' }, - { dtype: 'str', str: 'R1.Vwrong' }, - ] - }, undefined, undefined, testSurveyResponses)).toBeUndefined(); - - expect(expEval.eval({ - name: 'getResponseValueAsStr', data: [ - { dtype: 'str', str: 'TS.I1' }, - { dtype: 'str', str: 'R1.V1' }, - ] - }, undefined, undefined, testSurveyResponses)).toBeUndefined(); - - expect(expEval.eval({ - name: 'getResponseValueAsStr', data: [ - { dtype: 'str', str: 'TS.I1' }, - { dtype: 'str', str: 'R1.V2' }, - ] - }, undefined, undefined, testSurveyResponses)).toEqual("something"); + expect(expEval.eval(expression)).toBe(2.3); + }); + + it('should handle single argument', () => { + const editor = max(const_number(42)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(expression)).toBe(42); + }); + + it('should skip undefined values', () => { + const editor = max(const_number(5), response_string('survey.nonexistent...get'), const_number(3)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(expression)).toBe(5); + }); + + it('should throw error for non-numeric values', () => { + const editor = max(const_number(1), const_string('invalid')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(() => expEval.eval(expression)).toThrow('Max function expects all arguments to be numbers, got string'); + }); + + it('should throw error for no arguments', () => { + const expEval = new ExpressionEvaluator(); + const functionExpr = new FunctionExpression(FunctionExpressionNames.max, []); + + expect(() => expEval.eval(functionExpr)).toThrow('Max function expects at least 1 argument, got 0'); + }); + }); }); -test('testing expression: regexp', () => { - const expEval = new ExpressionEval(); - const testSurveyResponses: SurveyItemResponse = { - key: 'TS', - items: [ - { - key: 'TS.I1', - response: { - key: 'R1', +describe('Context Expression Evaluation', () => { + describe('ctx_locale', () => { + it('should return correct locale value', () => { + const editor = ctx_locale(); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en-US' } - }, - { - key: 'TS.I2', - response: { - key: 'R1', - value: 'test' + }); + + expect(expEval.eval(expression)).toBe('en-US'); + }); + + it('should return correct locale for different locale', () => { + const editor = ctx_locale(); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'de-DE' } - } - ] - } - - const regex1Exp: Expression = { - name: 'checkResponseValueWithRegex', data: [ - { dtype: 'str', str: 'TS.I1' }, - { dtype: 'str', str: 'R1' }, - { dtype: 'str', str: '.*\\S.*' }, - ] - }; - - const regex2Exp: Expression = { - name: 'checkResponseValueWithRegex', data: [ - { dtype: 'str', str: 'TS.I2' }, - { dtype: 'str', str: 'R1' }, - { dtype: 'str', str: '.*\\S.*' }, - ] - }; - - const regex3Exp: Expression = { - name: 'checkResponseValueWithRegex', data: [ - { dtype: 'str', str: 'TS.I2' }, - { dtype: 'str', str: 'R1' }, - { dtype: 'str', str: '\\d' }, - ] - }; - - - expect(expEval.eval(regex1Exp, undefined, undefined, testSurveyResponses)).toBeFalsy(); - expect(expEval.eval(regex2Exp, undefined, undefined, testSurveyResponses)).toBeTruthy(); - expect(expEval.eval(regex3Exp, undefined, undefined, testSurveyResponses)).toBeFalsy(); -}) - -test('testing expression: timestampWithOffset', () => { - const expEval = new ExpressionEval(); - - const withWrongType: Expression = { - name: 'timestampWithOffset', data: [ - { dtype: 'str', str: 'TS.I2' }, - { dtype: 'str', str: 'R1' }, - ] - }; - - const withMissingArgs: Expression = { - name: 'timestampWithOffset', - }; - - const withTooManyArgs: Expression = { - name: 'timestampWithOffset', - data: [ - { dtype: 'num', num: 22432 }, - { dtype: 'num', num: 342345342 }, - { dtype: 'num', num: 342345342 }, - ] - }; - - const withNowAsReference: Expression = { - name: 'timestampWithOffset', - data: [ - { dtype: 'num', num: -1000 }, - ] - }; - - const withAbsoluteReference: Expression = { - name: 'timestampWithOffset', - data: [ - { dtype: 'num', num: -1000 }, - { dtype: 'num', num: 2000 }, - ] - }; - - expect(expEval.eval(withWrongType, undefined, undefined, undefined)).toBeUndefined(); - expect(expEval.eval(withMissingArgs, undefined, undefined, undefined)).toBeUndefined(); - expect(expEval.eval(withTooManyArgs, undefined, undefined, undefined)).toBeUndefined(); - expect(expEval.eval(withNowAsReference, undefined, undefined, undefined)).toBeLessThan(Date.now() - 900); - expect(expEval.eval(withAbsoluteReference, undefined, undefined, undefined)).toEqual(1000); - -}) - -test('testing expression: countResponseItems', () => { - const expEval = new ExpressionEval(); - - const withWrongType: Expression = { - name: 'countResponseItems', data: [ - { dtype: 'str', str: 'TS.I2' }, - { dtype: 'num', num: 2 }, - ] - }; - - const withMissingArgs: Expression = { - name: 'countResponseItems', - }; - - const withTooManyArgs: Expression = { - name: 'countResponseItems', - data: [ - { dtype: 'str', str: 'TS.I2' }, - { dtype: 'str', str: 'rg.mcg' }, - { dtype: 'str', str: 'rg.mcg' }, - ] - }; - - const withCorrectExp: Expression = { - name: 'countResponseItems', - data: [ - { dtype: 'str', str: 'TS.I2' }, - { dtype: 'str', str: 'rg.mcg' }, - ] - }; - - expect(expEval.eval(withWrongType, undefined, undefined, undefined)).toEqual(-1); - expect(expEval.eval(withMissingArgs, undefined, undefined, undefined)).toEqual(-1); - expect(expEval.eval(withTooManyArgs, undefined, undefined, undefined)).toEqual(-1); - - // missing info - expect(expEval.eval(withCorrectExp, undefined, undefined, undefined)).toEqual(-1); - - - // missing question - expect(expEval.eval(withCorrectExp, undefined, undefined, { - key: 'TS', - items: [ - { - key: 'TS.other', - response: { - key: 'rg', - items: [{ key: 'mcg', items: [] }] + }); + + expect(expEval.eval(expression)).toBe('de-DE'); + }); + + it('should return undefined when no survey context', () => { + const editor = ctx_locale(); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator(); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + }); + + describe('ctx_pflag_is_defined', () => { + it('should return false when no flags are defined', () => { + const editor = ctx_pflag_is_defined(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en' } - } - ] - })).toEqual(-1); - - // missing response group - expect(expEval.eval(withCorrectExp, undefined, undefined, { - key: 'TS', - items: [ - { - key: 'TS.I2', - response: { - key: 'rg', - items: [{ key: 'scg', items: [] }] + }); + + expect(expEval.eval(expression)).toBe(false); + }); + + it('should return false when participantFlags is undefined', () => { + const editor = ctx_pflag_is_defined(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: undefined } - } - ] - })).toEqual(-1); - - // zero item - expect(expEval.eval(withCorrectExp, undefined, undefined, { - key: 'TS', - items: [ - { - key: 'TS.I2', - response: { - key: 'rg', - items: [{ key: 'mcg', items: [] }] + }); + + expect(expEval.eval(expression)).toBe(false); + }); + + it('should return false when flag key does not exist', () => { + const editor = ctx_pflag_is_defined(const_string('nonexistent')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: { + 'existing': 'value' + } } - } - ] - })).toEqual(0); - - // with items - expect(expEval.eval(withCorrectExp, undefined, undefined, { - key: 'TS', - items: [ - { - key: 'TS.I2', - response: { - key: 'rg', - items: [{ key: 'mcg', items: [{ key: '1' }] }] + }); + + expect(expEval.eval(expression)).toBe(false); + }); + + it('should return true when flag exists', () => { + const editor = ctx_pflag_is_defined(const_string('existing')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: { + 'existing': 'value' + } } - } - ] - })).toEqual(1); - expect(expEval.eval(withCorrectExp, undefined, undefined, { - key: 'TS', - items: [ - { - key: 'TS.I2', - response: { - key: 'rg', - items: [{ key: 'mcg', items: [{ key: '1' }, { key: '2' }, { key: '3' }] }] + }); + + expect(expEval.eval(expression)).toBe(true); + }); + + it('should return undefined when key is not a string', () => { + const editor = ctx_pflag_is_defined(const_number(123)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: { + 'existing': 'value' + } } - } - ] - })).toEqual(3); - - // combined exp: - const combExp: Expression = { - name: 'gt', - data: [ - { dtype: 'exp', exp: withCorrectExp }, - { dtype: 'num', num: 2 }, - ] - } - expect(expEval.eval(combExp, undefined, undefined, { - key: 'TS', - items: [ - { - key: 'TS.I2', - response: { - key: 'rg', - items: [{ key: 'mcg', items: [{ key: '1' }, { key: '2' }, { key: '3' }] }] + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + }); + + describe('ctx_pflag_string', () => { + it('should return undefined when no flags are defined', () => { + const editor = ctx_pflag_string(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en' } - } - ] - })).toBeTruthy(); -}) - -// ---------- ROOT REFERENCES ---------------- -test('testing expression: getContext', () => { - const expEval = new ExpressionEval(); - expect(expEval.eval({ name: 'getContext' })).toBeUndefined(); - - const testContext = { - mode: 'test', - participantFlags: { - prev: "1", - } - }; - expect(expEval.eval({ name: 'getContext' }, undefined, testContext)).toBeDefined(); - - expect(expEval.eval( - { - name: 'eq', data: [ - { - dtype: 'exp', exp: { - name: "getAttribute", - data: [ - { - dtype: 'exp', exp: { - name: "getAttribute", - data: [ - { dtype: 'exp', exp: { name: 'getContext' } }, - { dtype: 'str', str: 'participantFlags' } - ], - } - }, - { dtype: 'str', str: 'prev' } - ] + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should return undefined when participantFlags is undefined', () => { + const editor = ctx_pflag_string(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: undefined + } + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should return undefined when flag key does not exist', () => { + const editor = ctx_pflag_string(const_string('nonexistent')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: { + 'existing': 'value' } - }, - { dtype: 'str', str: '1' } - ] - } - , undefined, testContext - )).toBeTruthy(); -}) - -test('testing expression: getResponses', () => { - const expEval = new ExpressionEval(); - expect(expEval.eval({ name: 'getResponses' })).toBeUndefined(); - expect(expEval.eval({ name: 'getResponses' }, undefined, undefined, { - key: 'test', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - items: [] - })).toBeDefined(); -}) - -test('testing expression: getRenderedItems', () => { - const expEval = new ExpressionEval(); - expect(expEval.eval({ name: 'getRenderedItems' })).toBeUndefined(); - expect(expEval.eval({ name: 'getRenderedItems' }, { - key: 'test', - items: [] - })).toBeDefined(); -}) - -// ---------- WORKING WITH OBJECT/ARRAYS ---------------- -test('testing expression: getAttribute', () => { - const expEval = new ExpressionEval(); - - expect(expEval.eval( - { - name: 'getAttribute', - returnType: 'float', - data: [ - { dtype: 'exp', exp: { name: 'getContext' } }, - { dtype: 'str', str: 'profile' } - ] - } - , undefined, { - mode: 'test', - profile: 1.453, - })).toEqual(1.453); - - expect(expEval.eval( - { - name: 'getAttribute', - returnType: 'float', - data: [ - { dtype: 'exp', exp: { name: 'getContext' } }, - { dtype: 'str', str: 'notexisting' } - ] - } - , undefined, { - mode: 'test', - profile: 1, - })).toBeUndefined(); -}) - -test('testing expression: getArrayItemAtIndex', () => { - const expEval = new ExpressionEval(); - const testSurveyResponses: SurveyItemResponse = { - key: 'TS', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - items: [ - { - key: 'TS.I1', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - response: { - key: 'R1', - value: 'testvalue' } - }, - { - key: 'TS.I2', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - response: { - key: 'R1', - value: 'testvalue2' + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should return string value when flag exists', () => { + const editor = ctx_pflag_string(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: { + 'test': 'hello world' + } } - } - ] - } - - expect(expEval.eval( - { - name: 'getArrayItemAtIndex', - data: [ - { - dtype: 'exp', exp: { - name: 'getAttribute', data: [ - { dtype: 'exp', exp: { name: 'getResponses' } }, - { dtype: 'str', str: 'items' } - ] + }); + + expect(expEval.eval(expression)).toBe('hello world'); + }); + + it('should return empty string when flag value is empty', () => { + const editor = ctx_pflag_string(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: { + 'test': '' } - }, - { dtype: 'num', num: 0 } - ] - }, undefined, undefined, testSurveyResponses).response.value).toEqual('testvalue'); - - expect(expEval.eval( - { - name: 'getArrayItemAtIndex', - data: [ - { - dtype: 'exp', exp: { - name: 'getAttribute', data: [ - { dtype: 'exp', exp: { name: 'getResponses' } }, - { dtype: 'str', str: 'items' } - ] + } + }); + + expect(expEval.eval(expression)).toBe(''); + }); + + it('should return numeric string as string', () => { + const editor = ctx_pflag_string(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: { + 'test': '123.45' } - }, - { dtype: 'num', num: 1 } - ] - }, undefined, undefined, testSurveyResponses).response.value).toEqual('testvalue2'); - - expect(expEval.eval( - { - name: 'getArrayItemAtIndex', - data: [ - { - dtype: 'exp', exp: { - name: 'getAttribute', data: [ - { dtype: 'exp', exp: { name: 'getResponses' } }, - { dtype: 'str', str: 'items' } - ] + } + }); + + expect(expEval.eval(expression)).toBe('123.45'); + }); + + it('should handle special characters in flag value', () => { + const editor = ctx_pflag_string(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: { + 'test': 'value with spaces & symbols!' } - }, - { dtype: 'num', num: 2 } - ] - }, undefined, undefined, testSurveyResponses)).toBeUndefined(); -}) - -test('testing expression: getArrayItemByKey', () => { - const expEval = new ExpressionEval(); - const testSurveyResponses: SurveyItemResponse = { - key: 'TS', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - items: [ - { - key: 'TS.I1', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - response: { - key: 'R1', - value: 'testvalue' } - }, - { - key: 'TS.I2', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - response: { - key: 'R1', - value: 'testvalue2' + }); + + expect(expEval.eval(expression)).toBe('value with spaces & symbols!'); + }); + + it('should return undefined when key is not a string', () => { + const editor = ctx_pflag_string(const_number(123)); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: { + 'existing': 'value' + } } - } - ] - } - - expect(expEval.eval( - { - name: 'getArrayItemByKey', - data: [ - { - dtype: 'exp', exp: { - name: 'getAttribute', data: [ - { dtype: 'exp', exp: { name: 'getResponses' } }, - { dtype: 'str', str: 'items' } - ] + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + }); + + describe('ctx_pflag_num', () => { + it('should return undefined when no flags are defined', () => { + const editor = ctx_pflag_num(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en' + } + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should return undefined when participantFlags is undefined', () => { + const editor = ctx_pflag_num(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: undefined + } + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should return undefined when flag contains non-number string', () => { + const editor = ctx_pflag_num(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: { + 'test': 'not-a-number' + } + } + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should return number when flag contains valid number string', () => { + const editor = ctx_pflag_num(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: { + 'test': '42.5' } - }, - { dtype: 'str', str: 'TS.I1' }] - }, undefined, undefined, testSurveyResponses).response.value).toEqual('testvalue'); - - expect(expEval.eval( - { - name: 'getArrayItemByKey', - data: [ - { - dtype: 'exp', exp: { - name: 'getAttribute', data: [ - { dtype: 'exp', exp: { name: 'getResponses' } }, - { dtype: 'str', str: 'items' } - ] + } + }); + + expect(expEval.eval(expression)).toBe(42.5); + }); + + it('should handle negative numbers', () => { + const editor = ctx_pflag_num(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: { + 'test': '-123.45' } - }, - { dtype: 'str', str: 'TS.I2' }] - }, undefined, undefined, testSurveyResponses).response.value).toEqual('testvalue2'); - - expect(expEval.eval( - { - name: 'getArrayItemByKey', - data: [ - { - dtype: 'exp', exp: { - name: 'getAttribute', data: [ - { dtype: 'exp', exp: { name: 'getResponses' } }, - { dtype: 'str', str: 'items' } - ] + } + }); + + expect(expEval.eval(expression)).toBe(-123.45); + }); + + it('should handle zero', () => { + const editor = ctx_pflag_num(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: { + 'test': '0' } - }, - { dtype: 'str', str: 'TS.IWRONG' }] - }, undefined, undefined, testSurveyResponses)).toBeNull(); -}) - -test('testing expression: getObjByHierarchicalKey', () => { - const expEval = new ExpressionEval(); - const testSurveyResponses: SurveyItemResponse = { - key: 'TS', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - items: [ - { - key: 'TS.I1', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - response: { - key: 'R1', - value: 'testvalue' } - } - ] - } - - // Using survey item responses - expect(expEval.eval( - { - name: 'getObjByHierarchicalKey', - data: [ - { dtype: 'exp', exp: { name: 'getResponses' } }, - { dtype: 'str', str: 'TS.I1' }] - }, undefined, undefined, testSurveyResponses).response.value).toEqual('testvalue'); - - expect(expEval.eval({ - name: 'getObjByHierarchicalKey', - data: [ - { dtype: 'exp', exp: { name: 'getResponses' } }, - { dtype: 'str', str: 'TS.IWRONG' } - ] - }, undefined, undefined, testSurveyResponses)).toBeNull(); -}) - -test('testing expression: getResponseItem', () => { - const expEval = new ExpressionEval(); - const testSurveyResponses: SurveyItemResponse = { - key: 'TS', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - items: [ - { - key: 'TS.I1', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - response: { - key: 'RG1', - items: [ - { key: 'R1', value: 'testvalue' } - ] + }); + + expect(expEval.eval(expression)).toBe(0); + }); + }); + + describe('ctx_pflag_date', () => { + it('should return undefined when no flags are defined', () => { + const editor = ctx_pflag_date(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en' } - } - ] - } - - expect(expEval.eval({ - name: 'getResponseItem', - data: [ - { str: 'TS.I1' }, - { str: 'RG1.R1' } - ] - }, undefined, undefined, testSurveyResponses).value).toEqual('testvalue'); - - expect(expEval.eval({ - name: 'getResponseItem', - data: [ - { str: 'TS.I1' }, - { str: 'RG1' } - ] - }, undefined, undefined, testSurveyResponses).items).toHaveLength(1); - - expect(expEval.eval({ - name: 'getResponseItem', - data: [ - { str: 'TS.I1' }, - { str: 'SOMETHING' } - ] - }, undefined, undefined, testSurveyResponses)).toBeUndefined(); -}) - -test('testing expression: getSurveyItemValidation', () => { - const expEval = new ExpressionEval(); - const testRenderedSurveyItem: SurveySingleItem = { - key: 'TS', - type: 'test', - components: { - role: 'root', - items: [] - }, - validations: [ - { - key: 'v1', - type: 'hard', - rule: true - }, - { - key: 'v2', - type: 'hard', - rule: false - } - ] - } - - expect(expEval.eval({ - name: 'getSurveyItemValidation', - data: [ - { str: 'this' }, - { str: 'v1' } - ] - }, undefined, undefined, undefined, testRenderedSurveyItem)).toBeTruthy(); - - expect(expEval.eval({ - name: 'getSurveyItemValidation', - data: [ - { str: 'this' }, - { str: 'v2' } - ] - }, undefined, undefined, undefined, testRenderedSurveyItem)).toBeFalsy(); - - expect(expEval.eval({ - name: 'getSurveyItemValidation', - data: [ - { str: 'this' }, - { str: 'v3' } - ] - }, undefined, undefined, undefined, testRenderedSurveyItem)).toBeTruthy(); -}) - -// ---------- QUERY METHODS ---------------- -test('testing expression: findPreviousSurveyResponsesByKey', () => { - const context: SurveyContext = { - previousResponses: [ - { key: 'intake', versionId: 'wfdojsdfpo', submittedAt: 1000000, participantId: 'test', responses: [] }, - { key: 'weekly', versionId: 'wfdojsdfpo', submittedAt: 1200000, participantId: 'test', responses: [] }, - { key: 'weekly', versionId: 'wfdojsdfpo', submittedAt: 1300000, participantId: 'test', responses: [] } - ] - } - const expEval = new ExpressionEval(); - expect(expEval.eval({ name: 'findPreviousSurveyResponsesByKey', data: [{ str: 'weekly' }] })).toHaveLength(0); - expect(expEval.eval({ name: 'findPreviousSurveyResponsesByKey', data: [{ str: 'weekly' }] }, undefined, context)).toHaveLength(2); -}) - -test('testing expression: getLastFromSurveyResponses', () => { - const context: SurveyContext = { - previousResponses: [ - { key: 'intake', versionId: 'wfdojsdfpo', submittedAt: 1000000, participantId: 'test', responses: [] }, - { key: 'weekly', versionId: 'wfdojsdfpo', submittedAt: 1200000, participantId: 'test', responses: [] }, - { key: 'weekly', versionId: 'wfdojsdfpo', submittedAt: 1300000, participantId: 'test', responses: [] } - ] - } - - const expEval = new ExpressionEval(); - expect(expEval.eval({ name: 'getLastFromSurveyResponses', data: [{ str: 'weekly' }] })).toBeUndefined(); - expect(expEval.eval({ name: 'getLastFromSurveyResponses', data: [{ str: 'weekly' }] }, undefined, context).participantId).toEqual('test'); -}) - -test('testing expression: getPreviousResponses', () => { - const context: SurveyContext = { - previousResponses: [ - { key: 'intake', versionId: 'wfdojsdfpo', submittedAt: 1000000, participantId: 'test', responses: [] }, - { - key: 'weekly', versionId: 'wfdojsdfpo', submittedAt: 1200000, participantId: 'test', responses: [ - { key: 'weekly.q1', meta: { position: 0, rendered: [10], responded: [10], displayed: [10] }, response: { key: '1', value: 'test1' } }, - { key: 'weekly.q2', meta: { position: 0, rendered: [10], responded: [10], displayed: [10] }, response: { key: '1', value: 'test2' } } - ] - }, - { - key: 'weekly', versionId: 'wfdojsdfpo', submittedAt: 1300000, participantId: 'test', responses: [ - { key: 'weekly.q1', meta: { position: 0, rendered: [10], responded: [10], displayed: [10] }, response: { key: '1', value: 'test3' } }, - { key: 'weekly.q2', meta: { position: 0, rendered: [10], responded: [10], displayed: [10] }, response: { key: '1', value: 'test4' } } - ] - } - ] - } - - const expEval = new ExpressionEval(); - expect(expEval.eval({ name: 'getPreviousResponses', data: [{ str: 'weekly.q1' }] })).toHaveLength(0); - expect(expEval.eval({ name: 'getPreviousResponses', data: [{ str: 'weekly.q1' }] }, undefined, context)).toHaveLength(2); -}) - -test('testing expression: filterResponsesByIncludesKeys', () => { - const context: SurveyContext = { - previousResponses: [ - { key: 'intake', versionId: 'wfdojsdfpo', submittedAt: 1000000, participantId: 'test', responses: [] }, - { - key: 'weekly', versionId: 'wfdojsdfpo', submittedAt: 1200000, participantId: 'test', responses: [ - { key: 'weekly.q1', meta: { position: 0, rendered: [10], responded: [10], displayed: [10] }, response: { key: '1', value: 'test1' } }, - { - key: 'weekly.q2', meta: { position: 0, rendered: [10], responded: [10], displayed: [10] }, response: { - key: '1', items: [ - { key: '1', items: [{ key: '1' }] } - ] - } + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should return undefined when participantFlags is undefined', () => { + const editor = ctx_pflag_date(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: undefined + } + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should return undefined when flag contains non-number string', () => { + const editor = ctx_pflag_date(const_string('test')); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: { + 'test': 'not-a-timestamp' } - ] - }, - { - key: 'weekly', versionId: 'wfdojsdfpo', submittedAt: 1300000, participantId: 'test', responses: [ - { key: 'weekly.q1', meta: { position: 0, rendered: [10], responded: [10], displayed: [10] }, response: { key: '1', value: 'test3' } }, - { - key: 'weekly.q2', meta: { position: 0, rendered: [10], responded: [10], displayed: [10] }, response: { - key: '1', items: [ - { key: '1', items: [{ key: '1' }, { key: '2' }] } - ] - } + } + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should return Date when flag contains valid posix timestamp', () => { + const editor = ctx_pflag_date(const_string('test')); + const expression = editor.getExpression() as Expression; + const expectedDate = new Date('2023-01-01T00:00:00Z'); + const posixTimestamp = Math.floor(expectedDate.getTime() / 1000); + + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: { + 'test': posixTimestamp.toString() } - ] - } - ] - } - - const expEval = new ExpressionEval(); - expect(expEval.eval({ - name: 'filterResponsesByIncludesKeys', data: [ - { dtype: 'exp', exp: { name: 'getPreviousResponses', data: [{ str: 'weekly.q2' }] } }, - { str: '1.1' }, - { str: '2' }, - ] - })).toHaveLength(0); - - expect(expEval.eval({ - name: 'filterResponsesByIncludesKeys', data: [ - { dtype: 'exp', exp: { name: 'getPreviousResponses', data: [{ str: 'weekly.q2' }] } }, - { str: '1.1' }, - { str: '2' }, - ] - }, undefined, context)).toHaveLength(1); - - expect(expEval.eval({ - name: 'filterResponsesByIncludesKeys', data: [ - { dtype: 'exp', exp: { name: 'getPreviousResponses', data: [{ str: 'weekly.q2' }] } }, - { str: '1.1' }, - { str: '1' }, - { str: '2' }, - ] - }, undefined, context)).toHaveLength(1); - - expect(expEval.eval({ - name: 'filterResponsesByIncludesKeys', data: [ - { exp: { name: 'getPreviousResponses', data: [{ str: 'weekly.q2' }] } }, - { str: '1.1' }, - { str: '3' }, - ] - }, undefined, context)).toHaveLength(0); -}) - -test('testing expression: filterResponsesByValue', () => { - const context: SurveyContext = { - previousResponses: [ - { key: 'intake', versionId: 'wfdojsdfpo', submittedAt: 1000000, participantId: 'test', responses: [] }, - { - key: 'weekly', versionId: 'wfdojsdfpo', submittedAt: 1200000, participantId: 'test', responses: [ - { key: 'weekly.q1', meta: { position: 0, rendered: [10], responded: [10], displayed: [10] }, response: { key: '1', value: 'test1' } }, - { - key: 'weekly.q2', meta: { position: 0, rendered: [10], responded: [10], displayed: [10] }, response: { - key: '1', items: [ - { key: '1', value: 'test1' } - ] - } + } + }); + + expect(expEval.eval(expression)).toEqual(expectedDate); + }); + + it('should handle decimal timestamps', () => { + const editor = ctx_pflag_date(const_string('test')); + const expression = editor.getExpression() as Expression; + const posixTimestamp = 1672531200.5; // 2023-01-01T00:00:00.5Z + + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + participantFlags: { + 'test': posixTimestamp.toString() } - ] - }, - { - key: 'weekly', versionId: 'wfdojsdfpo', submittedAt: 1300000, participantId: 'test', responses: [ - { key: 'weekly.q1', meta: { position: 0, rendered: [10], responded: [10], displayed: [10] }, response: { key: '1', value: 'test3' } }, - { - key: 'weekly.q2', meta: { position: 0, rendered: [10], responded: [10], displayed: [10] }, response: { - key: '1', items: [ - { key: '1', value: 'test2' } - ] - } + } + }); + + expect(expEval.eval(expression)).toEqual(new Date(posixTimestamp * 1000)); + }); + }); + + describe('ctx_custom_value', () => { + it('should return undefined when customValues is empty', () => { + const editor = ctx_custom_value(const_string('test'), ExpectedValueType.String); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en' + } + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should return undefined when customValues is undefined', () => { + const editor = ctx_custom_value(const_string('test'), ExpectedValueType.String); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customValues: undefined + } + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should return undefined when key not found', () => { + const editor = ctx_custom_value(const_string('nonexistent'), ExpectedValueType.String); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customValues: { + 'existing': 'value' } - ] - } - ] - } - - const expEval = new ExpressionEval(); - expect(expEval.eval({ - name: 'filterResponsesByValue', data: [ - { dtype: 'exp', exp: { name: 'getPreviousResponses', data: [{ str: 'weekly.q2' }] } }, - { str: '1.1' }, - { str: 'test1' }, - ] - })).toHaveLength(0); - - expect(expEval.eval({ - name: 'filterResponsesByValue', data: [ - { dtype: 'exp', exp: { name: 'getPreviousResponses', data: [{ str: 'weekly.q2' }] } }, - { str: '1.1' }, - { str: 'test1' }, - ] - }, undefined, context)).toHaveLength(1); - - expect(expEval.eval({ - name: 'filterResponsesByValue', data: [ - { dtype: 'exp', exp: { name: 'getPreviousResponses', data: [{ str: 'weekly.q2' }] } }, - { str: '1.1' }, - { str: 'test2' }, - ] - }, undefined, context)).toHaveLength(1); - - expect(expEval.eval({ - name: 'filterResponsesByValue', data: [ - { exp: { name: 'getPreviousResponses', data: [{ str: 'weekly.q2' }] } }, - { str: '1.1' }, - { str: 'test3' }, - ] - }, undefined, context)).toHaveLength(0); -}) - - -test('testing expression: getLastFromSurveyItemResponses', () => { - const context: SurveyContext = { - previousResponses: [ - { key: 'intake', versionId: 'wfdojsdfpo', submittedAt: 1000000, participantId: 'test', responses: [] }, - { - key: 'weekly', versionId: 'wfdojsdfpo', submittedAt: 1200000, participantId: 'test', responses: [ - { key: 'weekly.q1', meta: { position: 0, rendered: [10], responded: [10], displayed: [10] }, response: { key: '1', value: 'test1' } }, - { - key: 'weekly.q2', meta: { position: 0, rendered: [10], responded: [10], displayed: [10] }, response: { - key: '1', items: [ - { key: '1', value: 'test1' } - ] - } + } + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should return value when key exists and type matches', () => { + const editor = ctx_custom_value(const_string('test'), ExpectedValueType.String); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customValues: { + 'test': 'hello world' } - ] - }, - { - key: 'weekly', versionId: 'wfdojsdfpo', submittedAt: 1300000, participantId: 'test', responses: [ - { key: 'weekly.q1', meta: { position: 0, rendered: [10], responded: [20], displayed: [10] }, response: { key: '1', value: 'test3' } }, - { - key: 'weekly.q2', meta: { position: 0, rendered: [10], responded: [20], displayed: [10] }, response: { - key: '1', items: [ - { key: '1', value: 'test2' } - ] - } + } + }); + + expect(expEval.eval(expression)).toBe('hello world'); + }); + + it('should return undefined when value type does not match expected type', () => { + const editor = ctx_custom_value(const_string('test'), ExpectedValueType.Number); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customValues: { + 'test': 'string value' } - ] - } - ] - } - - const expEval = new ExpressionEval(); - expect(expEval.eval({ - name: 'getLastFromSurveyItemResponses', data: [ - { dtype: 'exp', exp: { name: 'getPreviousResponses', data: [{ str: 'weekly.q2' }] } } - ] - })).toBeUndefined(); - - expect(expEval.eval({ - name: 'getLastFromSurveyItemResponses', data: [ - { dtype: 'exp', exp: { name: 'getPreviousResponses', data: [{ str: 'weekly.q2' }] } }, - ] - }, undefined, context).response.items[0].value).toEqual('test2'); -}) - -test('testing expression: getSecondsSince', () => { - const context: SurveyContext = { - previousResponses: [ - { key: 'intake', versionId: 'wfdojsdfpo', submittedAt: 1000000, participantId: 'test', responses: [] }, - { - key: 'weekly', versionId: 'wfdojsdfpo', submittedAt: 1200000, participantId: 'test', responses: [ - { key: 'weekly.q1', meta: { position: 0, rendered: [10], responded: [10], displayed: [10] }, response: { key: '1', value: 'test1' } }, - { - key: 'weekly.q2', meta: { position: 0, rendered: [10], responded: [10], displayed: [10] }, response: { - key: '1', items: [ - { key: '1.1', value: 'test1' } - ] - } + } + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should handle number values correctly', () => { + const editor = ctx_custom_value(const_string('test'), ExpectedValueType.Number); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customValues: { + 'test': 42.5 } - ] - }, - { - key: 'weekly', versionId: 'wfdojsdfpo', submittedAt: 1300000, participantId: 'test', responses: [ - { key: 'weekly.q1', meta: { position: 0, rendered: [10], responded: [20], displayed: [10] }, response: { key: '1', value: 'test3' } }, - { - key: 'weekly.q2', meta: { position: 0, rendered: [10], responded: [Date.now() / 1000 - 100], displayed: [10] }, response: { - key: '1', items: [ - { key: '1.1', value: 'test2' } - ] + } + }); + + expect(expEval.eval(expression)).toBe(42.5); + }); + + it('should handle boolean values correctly', () => { + const editor = ctx_custom_value(const_string('test'), ExpectedValueType.Boolean); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customValues: { + 'test': true + } + } + }); + + expect(expEval.eval(expression)).toBe(true); + }); + + it('should handle date values correctly', () => { + const editor = ctx_custom_value(const_string('test'), ExpectedValueType.Date); + const expression = editor.getExpression() as Expression; + const testDate = new Date('2023-01-01'); + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customValues: { + 'test': testDate + } + } + }); + + expect(expEval.eval(expression)).toBe(testDate); + }); + + it('should handle string array values correctly', () => { + const editor = ctx_custom_value(const_string('test'), ExpectedValueType.StringArray); + const expression = editor.getExpression() as Expression; + const testArray = ['hello', 'world']; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customValues: { + 'test': testArray + } + } + }); + + expect(expEval.eval(expression)).toEqual(testArray); + }); + }); + + describe('ctx_custom_expression', () => { + it('should return undefined when customExpressions is empty', () => { + const editor = ctx_custom_expression(const_string('test'), [], ExpectedValueType.String); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en' + } + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should return undefined when customExpressions is undefined', () => { + const editor = ctx_custom_expression(const_string('test'), [], ExpectedValueType.String); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customExpressions: undefined + } + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should return undefined when expression key not found', () => { + const editor = ctx_custom_expression(const_string('nonexistent'), [], ExpectedValueType.String); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customExpressions: { + 'existing': () => 'result' + } + } + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should return undefined when expression key exists but is not a function', () => { + const editor = ctx_custom_expression(const_string('test'), [], ExpectedValueType.String); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customExpressions: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 'test': 'not a function' as any + } + } + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should execute custom expression and return correct result', () => { + const editor = ctx_custom_expression(const_string('test'), [], ExpectedValueType.String); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customExpressions: { + 'test': () => 'hello world' + } + } + }); + + expect(expEval.eval(expression)).toBe('hello world'); + }); + + it('should pass arguments to custom expression', () => { + const editor = ctx_custom_expression( + const_string('test'), + [const_string('arg1'), const_number(42)], + ExpectedValueType.String + ); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customExpressions: { + 'test': (args) => { + // Custom expressions receive Expression arguments, not evaluated values + expect(args).toHaveLength(2); + return 'executed'; } } - ] - } - ] - } - - const expEval = new ExpressionEval(); - expect(expEval.eval({ - name: 'getSecondsSince', data: [ - { dtype: 'num', num: Date.now() / 1000 - 10 } - ] - })).toBeGreaterThanOrEqual(10); - expect(expEval.eval({ - name: 'getSecondsSince', data: [ - { dtype: 'num', num: Date.now() / 1000 - 10 } - ] - })).toBeLessThan(30); - - - // result is not a number - expect(expEval.eval({ - name: 'getSecondsSince', data: [ - { dtype: 'exp', exp: { name: 'getPreviousResponses', data: [{ str: 'weekly.q2' }] } } - ] - })).toBeUndefined(); - - const getLastResp: ExpressionArg = { - dtype: 'exp', exp: { - name: 'getLastFromSurveyItemResponses', data: [ - { dtype: 'exp', exp: { name: 'getPreviousResponses', data: [{ str: 'weekly.q2' }] } } - ] - } - }; - - const getMeta: ExpressionArg = { - dtype: 'exp', exp: { - name: 'getAttribute', data: [ - getLastResp, - { str: 'meta' } - ] - } - }; - - const getResponded: ExpressionArg = { - dtype: 'exp', exp: { - name: 'getAttribute', data: [ - getMeta, - { str: 'responded' } - ] - } - }; - - const expRes = expEval.eval({ - name: 'getSecondsSince', data: [ - { - dtype: 'exp', exp: { - name: 'getArrayItemAtIndex', data: [ - getResponded, - { dtype: 'num', num: 0 } - ] } - }, - ] - }, undefined, context); - expect(expRes).toBeGreaterThan(90); - expect(expRes).toBeLessThan(190); -}) - -test('testing expression: responseHasKeysAny', () => { - const expEval = new ExpressionEval(); - const testResp: SurveyGroupItemResponse = { - key: '1', - items: [ - { - key: '1.1', response: { - key: '1', - items: [{ - key: '1', - items: [{ - key: '1', - items: [ - { key: '1' }, - { key: '2' }, - { key: '3' }, - ] - }] - }] + }); + + expect(expEval.eval(expression)).toBe('executed'); + }); + + it('should return undefined when result type does not match expected type', () => { + const editor = ctx_custom_expression(const_string('test'), [], ExpectedValueType.Number); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customExpressions: { + 'test': () => 'string result' + } } - } - ] - } - - expect(expEval.eval( - { - name: 'responseHasKeysAny', data: [ - { str: '1.1' }, { str: '1.1.1' }, { str: '4' }, { str: '3' }, - ] - }, undefined, undefined, testResp - )).toBeTruthy(); - expect(expEval.eval( - { - name: 'responseHasKeysAny', data: [ - { str: '1.1' }, { str: '1.1.1' }, { str: '2' }, { str: '3' }, { str: '1' } - ] - }, undefined, undefined, testResp - )).toBeTruthy(); - expect(expEval.eval( - { - name: 'responseHasKeysAny', data: [ - { str: '1.1' }, { str: '1.1.1' }, { str: '4' }, { str: '5' }, - ] - }, undefined, undefined, testResp - )).toBeFalsy(); - expect(expEval.eval( - { - name: 'responseHasKeysAny', data: [ - { str: '1.1' }, { str: '1.1' }, { str: '4' }, { str: '5' }, - ] - }, undefined, undefined, testResp - )).toBeFalsy(); - expect(expEval.eval( - { - name: 'responseHasKeysAny', data: [ - { str: '1' }, { str: '1.1' }, { str: '4' }, { str: '5' }, - ] - }, undefined, undefined, testResp - )).toBeFalsy(); - - -}); - -test('testing expression: responseHasKeysAll', () => { - const expEval = new ExpressionEval(); - const testResp: SurveyGroupItemResponse = { - key: '1', - items: [ - { - key: '1.1', response: { - key: '1', - items: [{ - key: '1', - items: [{ - key: '1', - items: [ - { key: '1' }, - { key: '2' }, - { key: '3' }, - ] - }] - }] + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should handle custom expression that throws error', () => { + const editor = ctx_custom_expression(const_string('test'), [], ExpectedValueType.String); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customExpressions: { + 'test': () => { + throw new Error('Custom expression error'); + } + } } - } - ] - } - - expect(expEval.eval( - { - name: 'responseHasKeysAll', data: [ - { str: '1.1' }, { str: '1.1.1' }, { str: '4' }, { str: '3' }, - ] - }, undefined, undefined, testResp - )).toBeFalsy(); - expect(expEval.eval( - { - name: 'responseHasKeysAll', data: [ - { str: '1.1' }, { str: '1.1.1' }, { str: '2' }, { str: '3' }, { str: '1' } - ] - }, undefined, undefined, testResp - )).toBeTruthy(); - expect(expEval.eval( - { - name: 'responseHasKeysAll', data: [ - { str: '1.1' }, { str: '1.1.1' }, { str: '1' }, { str: '2' }, - ] - }, undefined, undefined, testResp - )).toBeFalsy(); - expect(expEval.eval( - { - name: 'responseHasKeysAll', data: [ - { str: '1.1' }, { str: '1.1' }, { str: '4' }, { str: '5' }, - ] - }, undefined, undefined, testResp - )).toBeFalsy(); - expect(expEval.eval( - { - name: 'responseHasKeysAll', data: [ - { str: '1' }, { str: '1.1' }, { str: '4' }, { str: '5' }, - ] - }, undefined, undefined, testResp - )).toBeFalsy(); - -}); - -test('testing expression: hasResponse', () => { - const expEval = new ExpressionEval(); - const testResp: SurveyGroupItemResponse = { - key: '1', - items: [ - { - key: '1.1', response: { - key: '1', - items: [{ - key: '1', - items: [{ - key: '1', - items: [ - { key: '1' }, - { key: '2' }, - { key: '3' }, - ] - }] - }] + }); + + expect(expEval.eval(expression)).toBeUndefined(); + }); + + it('should handle number return type correctly', () => { + const editor = ctx_custom_expression(const_string('test'), [], ExpectedValueType.Number); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customExpressions: { + 'test': () => 42.5 + } } - } - ] - } - - expect(expEval.eval( - { - name: 'hasResponse', data: [ - { str: '1.1' }, { str: '1.2' } - ] - }, undefined, undefined, testResp - )).toBeFalsy(); - expect(expEval.eval( - { - name: 'hasResponse', data: [ - { str: '1.2' }, { str: '1.1' } - ] - }, undefined, undefined, testResp - )).toBeFalsy(); - expect(expEval.eval( - { - name: 'hasResponse', data: [ - { str: '1.1' }, { str: '1.1' }, - ] - }, undefined, undefined, testResp - )).toBeTruthy(); -}); - -test('testing expression: responseHasOnlyKeysOtherThan', () => { - const expEval = new ExpressionEval(); - const testResp: SurveyGroupItemResponse = { - key: '1', - items: [ - { - key: '1.1', response: { - key: '1', - items: [{ - key: '1', - items: [{ - key: '1', - items: [ - { key: '1' }, - { key: '2' }, - { key: '3' }, - ] - }] - }] + }); + + expect(expEval.eval(expression)).toBe(42.5); + }); + + it('should handle boolean return type correctly', () => { + const editor = ctx_custom_expression(const_string('test'), [], ExpectedValueType.Boolean); + const expression = editor.getExpression() as Expression; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customExpressions: { + 'test': () => true + } } - } - ] - } - - expect(expEval.eval( - { - name: 'responseHasOnlyKeysOtherThan', data: [ - { str: '1.1' }, { str: '1.1.1' }, { str: '4' }, { str: '3' }, - ] - }, undefined, undefined, testResp - )).toBeFalsy(); - expect(expEval.eval( - { - name: 'responseHasOnlyKeysOtherThan', data: [ - { str: '1.1' }, { str: '1.1.1' }, { str: '2' }, { str: '3' }, { str: '1' } - ] - }, undefined, undefined, testResp - )).toBeFalsy(); - expect(expEval.eval( - { - name: 'responseHasOnlyKeysOtherThan', data: [ - { str: '1.1' }, { str: '1.1.1' }, { str: '4' }, { str: '5' }, - ] - }, undefined, undefined, testResp - )).toBeTruthy(); - expect(expEval.eval( - { - name: 'responseHasOnlyKeysOtherThan', data: [ - { str: '1.1' }, { str: '1.1' }, { str: '4' }, { str: '5' }, - ] - }, undefined, undefined, testResp - )).toBeTruthy(); - expect(expEval.eval( - { - name: 'responseHasOnlyKeysOtherThan', data: [ - { str: '1' }, { str: '1.1' }, { str: '4' }, { str: '5' }, - ] - }, undefined, undefined, testResp - )).toBeFalsy(); -}); - -test('testing expression: hasParticipantFlagKey', () => { - const expEval = new ExpressionEval(); - const testContext: SurveyContext = { - participantFlags: { - test: '2' - } - }; - - expect(expEval.eval( - { - name: 'hasParticipantFlagKey', data: [ - { str: 'test' } - ] - }, undefined, testContext - )).toBeTruthy(); - - expect(expEval.eval( - { - name: 'hasParticipantFlagKey', data: [ - { str: 'wrong' } - ] - }, undefined, testContext - )).toBeFalsy(); -}); - -test('testing expression: hasParticipantFlagKeyAndValue', () => { - const expEval = new ExpressionEval(); - const testContext: SurveyContext = { - participantFlags: { - test: '2' - } - }; - - expect(expEval.eval( - { - name: 'hasParticipantFlagKeyAndValue', data: [ - { str: 'test' }, { str: '2' } - ] - }, undefined, testContext - )).toBeTruthy(); - - expect(expEval.eval( - { - name: 'hasParticipantFlagKeyAndValue', data: [ - { str: 'wrong' }, { str: '2' } - ] - }, undefined, testContext - )).toBeFalsy(); - - expect(expEval.eval( - { - name: 'hasParticipantFlagKeyAndValue', data: [ - { str: 'test' }, { str: 'wrong' } - ] - }, undefined, testContext - )).toBeFalsy(); -}); - -test('testing expression: getParticipantFlagValue', () => { - const expEval = new ExpressionEval(); - const testContext: SurveyContext = { - participantFlags: { - test: '2' - } - }; - - expect(expEval.eval( - { - name: 'getParticipantFlagValue', data: [ - { str: 'test' } - ] - }, undefined, testContext - )).toEqual('2'); - - expect(expEval.eval( - { - name: 'getParticipantFlagValue', data: [ - { str: 'wrong' } - ] - }, undefined, testContext - )).toBeUndefined(); -}); - - -test('testing expression: validateSelectedOptionHasValueDefined', () => { - const expEval = new ExpressionEval(); - const testSurveyResponses: SurveyItemResponse = { - key: 'TS', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - items: [ - { - key: 'TS.I1', - meta: { position: 0, localeCode: 'de', rendered: [], displayed: [], responded: [] }, - response: { - key: 'R1', - items: [ - { key: 'V1', value: '' }, - { key: 'V2', value: '123.23' }, - { key: 'V3' } - ] + }); + + expect(expEval.eval(expression)).toBe(true); + }); + + it('should handle date return type correctly', () => { + const editor = ctx_custom_expression(const_string('test'), [], ExpectedValueType.Date); + const expression = editor.getExpression() as Expression; + const testDate = new Date('2023-01-01'); + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customExpressions: { + 'test': () => testDate + } } - } - ] - } - - expect(expEval.eval({ - name: 'validateSelectedOptionHasValueDefined', data: [ - { str: 'TS.I1' }, { str: 'R1.V1' }, - ] - }, undefined, undefined, testSurveyResponses)).toBeTruthy(); - - expect(expEval.eval({ - name: 'validateSelectedOptionHasValueDefined', data: [ - { str: 'TS.I1' }, { str: 'R1.V2' }, - ] - }, undefined, undefined, testSurveyResponses)).toBeTruthy(); - - expect(expEval.eval({ - name: 'validateSelectedOptionHasValueDefined', data: [ - { str: 'TS.I1' }, { str: 'R1.V3' }, - ] - }, undefined, undefined, testSurveyResponses)).toBeFalsy(); - - expect(expEval.eval({ - name: 'validateSelectedOptionHasValueDefined', data: [ - { str: 'TS.I1' }, { str: 'R1.V4' }, - ] - }, undefined, undefined, testSurveyResponses)).toBeTruthy(); - -}) - -test('testing expression: dateResponseDiffFromNow', () => { - const expEval = new ExpressionEval(); - const testResp: SurveyGroupItemResponse = { - key: '1', - items: [ - { - key: '1.1', response: { - key: '1', - items: [{ - key: '1', - items: [{ - key: '1', - items: [ - { key: '1', dtype: 'date', value: getUnixTime(add(new Date(), { years: -2 })).toString() }, - { key: '2', dtype: 'date', value: getUnixTime(add(new Date(), { months: 18 })).toString() }, - { key: '3', value: '15323422332' }, - ] - }] - }] + }); + + expect(expEval.eval(expression)).toBe(testDate); + }); + + it('should handle array return types correctly', () => { + const editor = ctx_custom_expression(const_string('test'), [], ExpectedValueType.StringArray); + const expression = editor.getExpression() as Expression; + const testArray = ['hello', 'world']; + const expEval = new ExpressionEvaluator({ + responses: {}, + surveyContext: { + locale: 'en', + customExpressions: { + 'test': () => testArray + } } - } - ] - } - - expect(expEval.eval( - { - name: 'dateResponseDiffFromNow', data: [ - { str: '1.2' }, { str: '1.1.1.1' }, { str: 'years' }, { num: 1 }, - ] - }, undefined, undefined, testResp - )).toBeUndefined(); - - expect(expEval.eval( - { - name: 'dateResponseDiffFromNow', data: [ - { str: '1.1' }, { str: '1.1.1.no' }, { str: 'years' }, { num: 1 }, - ] - }, undefined, undefined, testResp - )).toBeUndefined(); - - expect(expEval.eval( - { - name: 'dateResponseDiffFromNow', data: [ - { str: '1.1' }, { str: '1.1.1.3' }, { str: 'years' }, { num: 1 }, - ] - }, undefined, undefined, testResp - )).toBeUndefined(); - - expect(expEval.eval( - { - name: 'dateResponseDiffFromNow', data: [ - { str: '1.1' }, { str: '1.1.1.1' }, { str: 'years' }, { num: 1 }, - ] - }, undefined, undefined, testResp - )).toEqual(2); - - expect(expEval.eval( - { - name: 'dateResponseDiffFromNow', data: [ - { str: '1.1' }, { str: '1.1.1.1' }, { str: 'months' }, - ] - }, undefined, undefined, testResp - )).toEqual(-24); - - expect(expEval.eval( - { - name: 'dateResponseDiffFromNow', data: [ - { str: '1.1' }, { str: '1.1.1.2' }, { str: 'months' }, - ] - }, undefined, undefined, testResp - )).toEqual(17); - - expect(expEval.eval( - { - name: 'dateResponseDiffFromNow', data: [ - { str: '1.1' }, { str: '1.1.1.2' }, { str: 'years' }, - ] - }, undefined, undefined, testResp - )).toEqual(1); + }); + + expect(expEval.eval(expression)).toEqual(testArray); + }); + }); }); diff --git a/src/__tests__/initial.test.ts b/src/__tests__/initial.test.ts deleted file mode 100644 index 7edee2f..0000000 --- a/src/__tests__/initial.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { SurveyEngineCore } from '../engine'; -import { SurveyContext, SurveyGroupItem, ResponseItem, Survey } from '../data_types'; - -// import qg4 from '../../test-surveys/qg4.json' -import { printSurveyItem, printResponses } from '../utils'; - -test('Op Test', () => { - // console.log(simpleSurvey1); - // const surveyDef: SurveyGroupItem = simpleSurvey1 as SurveyGroupItem; - /*const surveyDef: SurveyGroupItem = qg4 as SurveyGroupItem; - const testSurvey: Survey = { - current: { - versionId: 'wfdojsdfpo', - surveyDefinition: surveyDef - } - - } - const context: SurveyContext = { - mode: 'test' - }; - const surveyE = new SurveyEngineCore( - testSurvey, - context - ); - - printSurveyItem(surveyE.getRenderedSurvey(), ''); - surveyE.questionDisplayed('QG0.QG4.Q4'); - surveyE.questionDisplayed('QG0.QG4.Q4'); - - //console.log(JSON.stringify(surveyE.getRenderedSurvey(), null, 2)); - const resp: ResponseItem = { - key: 'RG1', - items: [ - { key: 'RG1.R1' } - ] - // value: "14", - // dtype: 'number', - } - surveyE.setResponse('QG0.QG4.Q4', resp); - printResponses(surveyE.getResponses(), ''); - printSurveyItem(surveyE.getRenderedSurvey(), ''); - - //console.log(JSON.stringify(surveyE.getRenderedSurvey(), null, 2)); - //console.log(JSON.stringify(surveyE.getResponses(), null, 2)); - */ -}); diff --git a/src/__tests__/item-component-key.test.ts b/src/__tests__/item-component-key.test.ts new file mode 100644 index 0000000..2c5506b --- /dev/null +++ b/src/__tests__/item-component-key.test.ts @@ -0,0 +1,349 @@ +import { SurveyItemKey, ItemComponentKey } from '../survey/item-component-key'; + +describe('SurveyItemKey', () => { + describe('constructor', () => { + it('should create a root item key when no parent is provided', () => { + const itemKey = new SurveyItemKey('item1'); + + expect(itemKey.itemKey).toBe('item1'); + expect(itemKey.fullKey).toBe('item1'); + expect(itemKey.isRoot).toBe(true); + expect(itemKey.parentFullKey).toBeUndefined(); + expect(itemKey.parentKey).toBeUndefined(); + expect(itemKey.keyParts).toEqual(['item1']); + }); + + it('should create a nested item key when parent is provided', () => { + const itemKey = new SurveyItemKey('item2', 'group1'); + + expect(itemKey.itemKey).toBe('item2'); + expect(itemKey.fullKey).toBe('group1.item2'); + expect(itemKey.isRoot).toBe(false); + expect(itemKey.parentFullKey).toBe('group1'); + expect(itemKey.parentKey).toBe('group1'); + expect(itemKey.keyParts).toEqual(['group1', 'item2']); + }); + + it('should create a deeply nested item key', () => { + const itemKey = new SurveyItemKey('item3', 'group1.subgroup1'); + + expect(itemKey.itemKey).toBe('item3'); + expect(itemKey.fullKey).toBe('group1.subgroup1.item3'); + expect(itemKey.isRoot).toBe(false); + expect(itemKey.parentFullKey).toBe('group1.subgroup1'); + expect(itemKey.parentKey).toBe('subgroup1'); + expect(itemKey.keyParts).toEqual(['group1', 'subgroup1', 'item3']); + }); + + it('should handle empty parent key', () => { + const itemKey = new SurveyItemKey('item1', ''); + + expect(itemKey.itemKey).toBe('item1'); + expect(itemKey.fullKey).toBe('item1'); + expect(itemKey.isRoot).toBe(false); + expect(itemKey.parentFullKey).toBe(''); + expect(itemKey.parentKey).toBeUndefined(); + expect(itemKey.keyParts).toEqual(['item1']); + }); + + it('should throw error when item key contains a dot', () => { + expect(() => { + new SurveyItemKey('item.with.dot'); + }).toThrow('Item key must not contain a dot (.)'); + }); + + it('should throw error when item key contains a dot with parent', () => { + expect(() => { + new SurveyItemKey('item.with.dot', 'parent'); + }).toThrow('Item key must not contain a dot (.)'); + }); + + it('should throw error when item key contains single dot', () => { + expect(() => { + new SurveyItemKey('item.key', 'group1.subgroup1'); + }).toThrow('Item key must not contain a dot (.)'); + }); + }); + + describe('fromFullKey', () => { + it('should create item key from simple full key', () => { + const itemKey = SurveyItemKey.fromFullKey('item1'); + + expect(itemKey.itemKey).toBe('item1'); + expect(itemKey.fullKey).toBe('item1'); + expect(itemKey.isRoot).toBe(true); + expect(itemKey.parentFullKey).toBeUndefined(); + expect(itemKey.parentKey).toBeUndefined(); + }); + + it('should create item key from nested full key', () => { + const itemKey = SurveyItemKey.fromFullKey('group1.item2'); + + expect(itemKey.itemKey).toBe('item2'); + expect(itemKey.fullKey).toBe('group1.item2'); + expect(itemKey.isRoot).toBe(false); + expect(itemKey.parentFullKey).toBe('group1'); + expect(itemKey.parentKey).toBe('group1'); + }); + + it('should create item key from deeply nested full key', () => { + const itemKey = SurveyItemKey.fromFullKey('group1.subgroup1.subgroup2.item3'); + + expect(itemKey.itemKey).toBe('item3'); + expect(itemKey.fullKey).toBe('group1.subgroup1.subgroup2.item3'); + expect(itemKey.isRoot).toBe(false); + expect(itemKey.parentFullKey).toBe('group1.subgroup1.subgroup2'); + expect(itemKey.parentKey).toBe('subgroup2'); + expect(itemKey.keyParts).toEqual(['group1', 'subgroup1', 'subgroup2', 'item3']); + }); + + it('should handle keys with dots in the name', () => { + const itemKey = SurveyItemKey.fromFullKey('group.with.dots.item.with.dots'); + + expect(itemKey.itemKey).toBe('dots'); + expect(itemKey.fullKey).toBe('group.with.dots.item.with.dots'); + expect(itemKey.parentFullKey).toBe('group.with.dots.item.with'); + expect(itemKey.parentKey).toBe('with'); + }); + }); +}); + +describe('ItemComponentKey', () => { + describe('constructor', () => { + it('should create a root component key', () => { + const componentKey = new ItemComponentKey('rg', undefined, 'item1'); + + expect(componentKey.componentKey).toBe('rg'); + expect(componentKey.fullKey).toBe('rg'); + expect(componentKey.isRoot).toBe(true); + expect(componentKey.parentFullKey).toBeUndefined(); + expect(componentKey.parentKey).toBeUndefined(); + expect(componentKey.keyParts).toEqual(['rg']); + expect(componentKey.parentItemKey.itemKey).toBe('item1'); + expect(componentKey.parentItemKey.fullKey).toBe('item1'); + }); + + it('should create a nested component key', () => { + const componentKey = new ItemComponentKey('input', 'rg', 'item1'); + + expect(componentKey.componentKey).toBe('input'); + expect(componentKey.fullKey).toBe('rg.input'); + expect(componentKey.isRoot).toBe(false); + expect(componentKey.parentFullKey).toBe('rg'); + expect(componentKey.parentKey).toBe('rg'); + expect(componentKey.keyParts).toEqual(['rg', 'input']); + expect(componentKey.parentItemKey.itemKey).toBe('item1'); + expect(componentKey.parentItemKey.fullKey).toBe('item1'); + }); + + it('should create a deeply nested component key', () => { + const componentKey = new ItemComponentKey('option1', 'rg.scg', 'group1.item2'); + + expect(componentKey.componentKey).toBe('option1'); + expect(componentKey.fullKey).toBe('rg.scg.option1'); + expect(componentKey.isRoot).toBe(false); + expect(componentKey.parentFullKey).toBe('rg.scg'); + expect(componentKey.parentKey).toBe('scg'); + expect(componentKey.keyParts).toEqual(['rg', 'scg', 'option1']); + expect(componentKey.parentItemKey.itemKey).toBe('item2'); + expect(componentKey.parentItemKey.fullKey).toBe('group1.item2'); + expect(componentKey.parentItemKey.parentFullKey).toBe('group1'); + }); + + it('should handle nested item keys correctly', () => { + const componentKey = new ItemComponentKey('textarea', 'rg', 'group1.subgroup1.item3'); + + expect(componentKey.componentKey).toBe('textarea'); + expect(componentKey.fullKey).toBe('rg.textarea'); + expect(componentKey.parentItemKey.itemKey).toBe('item3'); + expect(componentKey.parentItemKey.fullKey).toBe('group1.subgroup1.item3'); + expect(componentKey.parentItemKey.parentFullKey).toBe('group1.subgroup1'); + expect(componentKey.parentItemKey.parentKey).toBe('subgroup1'); + expect(componentKey.parentItemKey.keyParts).toEqual(['group1', 'subgroup1', 'item3']); + }); + + it('should handle empty parent component key', () => { + const componentKey = new ItemComponentKey('rg', '', 'item1'); + + expect(componentKey.componentKey).toBe('rg'); + expect(componentKey.fullKey).toBe('rg'); + expect(componentKey.isRoot).toBe(false); + expect(componentKey.parentFullKey).toBe(''); + expect(componentKey.parentKey).toBeUndefined(); + expect(componentKey.parentItemKey.itemKey).toBe('item1'); + }); + + it('should throw error when component key contains a dot', () => { + expect(() => { + new ItemComponentKey('comp.with.dot', 'parent', 'item1'); + }).toThrow('Component key must not contain a dot (.)'); + }); + + it('should throw error when component key contains a dot without parent', () => { + expect(() => { + new ItemComponentKey('comp.key', undefined, 'item1'); + }).toThrow('Component key must not contain a dot (.)'); + }); + + it('should throw error when component key contains single dot', () => { + expect(() => { + new ItemComponentKey('comp.key', 'rg', 'group1.item1'); + }).toThrow('Component key must not contain a dot (.)'); + }); + }); + + describe('integration with SurveyItemKey', () => { + it('should correctly reference parent item key with complex structure', () => { + const componentKey = new ItemComponentKey( + 'option2', + 'rg.scg.mc', + 'survey.page1.group1.question1' + ); + + expect(componentKey.componentKey).toBe('option2'); + expect(componentKey.fullKey).toBe('rg.scg.mc.option2'); + expect(componentKey.parentItemKey.itemKey).toBe('question1'); + expect(componentKey.parentItemKey.fullKey).toBe('survey.page1.group1.question1'); + expect(componentKey.parentItemKey.parentFullKey).toBe('survey.page1.group1'); + expect(componentKey.parentItemKey.parentKey).toBe('group1'); + expect(componentKey.parentItemKey.isRoot).toBe(false); + }); + + it('should handle single-level item keys', () => { + const componentKey = new ItemComponentKey('input', 'rg', 'Q1'); + + expect(componentKey.parentItemKey.itemKey).toBe('Q1'); + expect(componentKey.parentItemKey.fullKey).toBe('Q1'); + expect(componentKey.parentItemKey.parentFullKey).toBeUndefined(); + expect(componentKey.parentItemKey.isRoot).toBe(true); + }); + }); +}); + +describe('Edge cases and error handling', () => { + it('should handle keys with special characters', () => { + const itemKey = new SurveyItemKey('item-with_special$chars', 'parent-key'); + + expect(itemKey.itemKey).toBe('item-with_special$chars'); + expect(itemKey.fullKey).toBe('parent-key.item-with_special$chars'); + }); + + it('should handle very long key names', () => { + const longKey = 'a'.repeat(100); + const itemKey = new SurveyItemKey(longKey); + + expect(itemKey.itemKey).toBe(longKey); + expect(itemKey.fullKey).toBe(longKey); + }); + + it('should handle keys with numbers', () => { + const itemKey = new SurveyItemKey('item123', 'group456'); + + expect(itemKey.itemKey).toBe('item123'); + expect(itemKey.fullKey).toBe('group456.item123'); + }); + + it('should handle component keys with complex parent item structures', () => { + const componentKey = new ItemComponentKey( + 'comp123', + 'parent.comp', + 'level1.level2.level3.level4.item' + ); + + expect(componentKey.componentKey).toBe('comp123'); + expect(componentKey.parentItemKey.itemKey).toBe('item'); + expect(componentKey.parentItemKey.keyParts).toHaveLength(5); + }); +}); + +describe('Key validation', () => { + describe('SurveyItemKey validation', () => { + it('should allow keys with valid characters', () => { + expect(() => new SurveyItemKey('validKey123_-$')).not.toThrow(); + expect(() => new SurveyItemKey('item', 'parent.group')).not.toThrow(); + }); + + it('should throw detailed error message for item key with dot', () => { + expect(() => new SurveyItemKey('invalid.key')).toThrow('Item key must not contain a dot (.)'); + }); + + it('should throw error when parent key starts with a dot', () => { + expect(() => { + new SurveyItemKey('item1', '.parent'); + }).toThrow('Parent key must not start with a dot (.)'); + }); + + it('should throw error when parent key ends with a dot', () => { + expect(() => { + new SurveyItemKey('item1', 'parent.'); + }).toThrow('Parent key must not end with a dot (.)'); + }); + + it('should throw error when parent key starts and ends with dots', () => { + expect(() => { + new SurveyItemKey('item1', '.parent.'); + }).toThrow('Parent key must not start with a dot (.)'); + }); + + it('should throw error when parent key starts with dot in nested structure', () => { + expect(() => { + new SurveyItemKey('item1', '.group.subgroup'); + }).toThrow('Parent key must not start with a dot (.)'); + }); + + it('should throw error when parent key ends with dot in nested structure', () => { + expect(() => { + new SurveyItemKey('item1', 'group.subgroup.'); + }).toThrow('Parent key must not end with a dot (.)'); + }); + + it('should allow empty parent key', () => { + expect(() => new SurveyItemKey('item1', '')).not.toThrow(); + }); + }); + + describe('ItemComponentKey validation', () => { + it('should allow component keys with valid characters', () => { + expect(() => new ItemComponentKey('validComponent123_-$', 'parent', 'item1')).not.toThrow(); + expect(() => new ItemComponentKey('rg', 'parent.component', 'group.item')).not.toThrow(); + }); + + it('should throw detailed error message for component key with dot', () => { + expect(() => new ItemComponentKey('invalid.component', 'parent', 'item1')).toThrow('Component key must not contain a dot (.)'); + }); + + it('should throw error when parent component key starts with a dot', () => { + expect(() => { + new ItemComponentKey('comp1', '.parent', 'item1'); + }).toThrow('Parent key must not start with a dot (.)'); + }); + + it('should throw error when parent component key ends with a dot', () => { + expect(() => { + new ItemComponentKey('comp1', 'parent.', 'item1'); + }).toThrow('Parent key must not end with a dot (.)'); + }); + + it('should throw error when parent component key starts and ends with dots', () => { + expect(() => { + new ItemComponentKey('comp1', '.parent.', 'item1'); + }).toThrow('Parent key must not start with a dot (.)'); + }); + + it('should throw error when parent component key starts with dot in nested structure', () => { + expect(() => { + new ItemComponentKey('comp1', '.rg.scg', 'item1'); + }).toThrow('Parent key must not start with a dot (.)'); + }); + + it('should throw error when parent component key ends with dot in nested structure', () => { + expect(() => { + new ItemComponentKey('comp1', 'rg.scg.', 'item1'); + }).toThrow('Parent key must not end with a dot (.)'); + }); + + it('should allow empty parent component key', () => { + expect(() => new ItemComponentKey('comp1', '', 'item1')).not.toThrow(); + }); + }); +}); diff --git a/src/__tests__/legacy-conversion.test.ts b/src/__tests__/legacy-conversion.test.ts new file mode 100644 index 0000000..a34ffad --- /dev/null +++ b/src/__tests__/legacy-conversion.test.ts @@ -0,0 +1,333 @@ +/* +TODO: +import { convertLegacyToNewSurvey, convertNewToLegacySurvey } from '../legacy-conversion'; +import { + LegacySurvey, + LegacySurveyGroupItem, + LegacySurveySingleItem, + LegacyItemGroupComponent, + LegacyResponseComponent +} from '../data_types/legacy-types'; +import { + Survey, + SurveyGroupItem, + SurveySingleItem, + ItemGroupComponent, + ResponseComponent, + LocalizedContent +} from '../data_types'; + +describe('Legacy Conversion Tests', () => { + + test('convertLegacyToNewSurvey should convert basic legacy survey structure', () => { + const legacySurvey: LegacySurvey = { + versionId: '1.0.0', + id: 'survey1', + surveyDefinition: { + key: 'survey1', + items: [{ + key: 'survey1.question1', + components: { + role: 'root', + items: [], + content: [{ + code: 'en', + parts: [{ str: 'What is your name?', dtype: 'str' }] + }, { + code: 'es', + parts: [{ str: '¿Cuál es tu nombre?', dtype: 'str' }] + }] + }, + validations: [{ + key: 'required', + type: 'hard', + rule: { name: 'hasResponse' } + }] + } as LegacySurveySingleItem] + } as LegacySurveyGroupItem + }; + + const newSurvey = convertLegacyToNewSurvey(legacySurvey); + + expect(newSurvey.versionId).toBe('1.0.0'); + expect(newSurvey.id).toBe('survey1'); + expect(newSurvey.surveyDefinition.key).toBe('survey1'); + expect(newSurvey.surveyDefinition.items).toHaveLength(1); + + const singleItem = newSurvey.surveyDefinition.items[0] as SurveySingleItem; + expect(singleItem.key).toBe('survey1.question1'); + expect(singleItem.validations).toHaveLength(1); + expect(singleItem.validations![0].key).toBe('required'); + + // Check that translations were extracted correctly + expect(singleItem.components?.translations).toBeDefined(); + expect(singleItem.components?.translations!['en']).toBeDefined(); + expect(singleItem.components?.translations!['es']).toBeDefined(); + }); + + test('convertNewToLegacySurvey should convert new survey back to legacy format', () => { + const newSurvey: Survey = { + schemaVersion: 1, + versionId: '1.0.0', + id: 'test-survey', + surveyDefinition: { + key: 'root', + items: [{ + key: 'question1', + type: 'test', + components: { + role: 'root', + items: [], + content: [{ type: 'plain', key: 'questionText' }] as LocalizedContent[], + translations: { + 'en': { 'questionText': 'What is your name?' }, + 'es': { 'questionText': '¿Cuál es tu nombre?' } + } + }, + validations: [{ + key: 'required', + type: 'hard', + rule: { name: 'hasResponse' } + }] + } as SurveySingleItem] + } as SurveyGroupItem + }; + + const legacySurvey = convertNewToLegacySurvey(newSurvey); + + expect(legacySurvey.versionId).toBe('1.0.0'); + expect(legacySurvey.id).toBe('test-survey'); + expect(legacySurvey.surveyDefinition.key).toBe('root'); + expect(legacySurvey.surveyDefinition.items).toHaveLength(1); + + const singleItem = legacySurvey.surveyDefinition.items[0] as LegacySurveySingleItem; + expect(singleItem.key).toBe('question1'); + expect(singleItem.type).toBe('test'); + expect(singleItem.validations).toHaveLength(1); + expect(singleItem.validations![0].key).toBe('required'); + + // Check that content was converted correctly + expect(singleItem.components?.content).toBeDefined(); + expect(singleItem.components?.content).toHaveLength(1); + expect(singleItem.components?.content![0].code).toBe('en'); + expect(singleItem.components?.content![0].parts).toHaveLength(1); + expect(singleItem.components?.content![0].parts![0].str).toBe('What is your name?'); + expect(singleItem.components?.content![0].parts![0].dtype).toBe('str'); + }); + + test('should handle nested component structures during conversion', () => { + const legacySurvey: LegacySurvey = { + versionId: '2.0.0', + surveyDefinition: { + key: 'survey1', + items: [{ + key: 'survey1.question1', + components: { + role: 'root', + items: [{ + role: 'responseGroup', + key: 'rg1', + items: [{ + role: 'input', + key: 'textInput', + dtype: 'string' + } as LegacyResponseComponent], + content: [{ + code: 'en', + parts: [{ str: 'Response Group Label', dtype: 'str' }], + resolvedText: 'Response Group Label' + }] + } as LegacyItemGroupComponent], + content: [{ + code: 'en', + parts: [{ str: 'Root Question', dtype: 'str' }], + resolvedText: 'Root Question' + }] + } + } as LegacySurveySingleItem] + } as LegacySurveyGroupItem + }; + + const newSurvey = convertLegacyToNewSurvey(legacySurvey); + const singleItem = newSurvey.surveyDefinition.items[0] as SurveySingleItem; + const rootComponent = singleItem.components as ItemGroupComponent; + + expect(rootComponent.items).toHaveLength(1); + expect(rootComponent.translations!['en']).toBeDefined(); + + const responseGroup = rootComponent.items[0] as ItemGroupComponent; + expect(responseGroup.role).toBe('responseGroup'); + expect(responseGroup.key).toBe('rg1'); + expect(responseGroup.items).toHaveLength(1); + expect(responseGroup.translations!['en']).toBeDefined(); + + const inputComponent = responseGroup.items[0] as ResponseComponent; + expect(inputComponent.role).toBe('input'); + expect(inputComponent.key).toBe('textInput'); + expect(inputComponent.dtype).toBe('string'); + }); + + test('should preserve survey props during conversion', () => { + const legacySurvey: LegacySurvey = { + versionId: '1.0.0', + props: { + name: [{ + code: 'en', + parts: [{ str: 'Test Survey', dtype: 'str' }], + }], + description: [{ + code: 'en', + parts: [{ str: 'A test survey description', dtype: 'str' }], + }] + }, + surveyDefinition: { + key: 'root', + items: [] + } as LegacySurveyGroupItem + }; + + const newSurvey = convertLegacyToNewSurvey(legacySurvey); + + expect(newSurvey.props).toBeDefined(); + expect(newSurvey.props!.name).toBeDefined(); + expect(newSurvey.props!.description).toBeDefined(); + expect(newSurvey.props!.name!.key).toBe('Test Survey'); + expect(newSurvey.props!.description!.key).toBe('A test survey description'); + }); + + test('should handle survey groups correctly', () => { + const legacySurvey: LegacySurvey = { + versionId: '1.0.0', + surveyDefinition: { + key: 'root', + items: [{ + key: 'group1', + items: [{ + key: 'question1', + type: 'test' + } as LegacySurveySingleItem, { + key: 'question2', + type: 'test' + } as LegacySurveySingleItem] + } as LegacySurveyGroupItem] + } as LegacySurveyGroupItem + }; + + const newSurvey = convertLegacyToNewSurvey(legacySurvey); + const group = newSurvey.surveyDefinition.items[0] as SurveyGroupItem; + + expect(group.key).toBe('group1'); + expect(group.items).toHaveLength(2); + expect((group.items[0] as SurveySingleItem).key).toBe('question1'); + expect((group.items[1] as SurveySingleItem).key).toBe('question2'); + }); + + test('conversion should be reversible for basic structures', () => { + const originalLegacy: LegacySurvey = { + versionId: '1.0.0', + id: 'reversible-test', + surveyDefinition: { + key: 'root', + items: [{ + key: 'question1', + type: 'test', + components: { + role: 'root', + items: [], + content: [{ + code: 'en', + parts: [{ str: 'Test Question', dtype: 'str' }], + resolvedText: 'Test Question' + }] + } + } as LegacySurveySingleItem] + } as LegacySurveyGroupItem + }; + + // Legacy -> New -> Legacy + const converted = convertLegacyToNewSurvey(originalLegacy); + const backToLegacy = convertNewToLegacySurvey(converted); + + expect(backToLegacy.versionId).toBe(originalLegacy.versionId); + expect(backToLegacy.id).toBe(originalLegacy.id); + expect(backToLegacy.surveyDefinition.key).toBe(originalLegacy.surveyDefinition.key); + expect(backToLegacy.surveyDefinition.items).toHaveLength(1); + + const item = backToLegacy.surveyDefinition.items[0] as LegacySurveySingleItem; + expect(item.key).toBe('question1'); + expect(item.type).toBe('test'); + }); + + test('should always set schemaVersion to 1 when converting from legacy', () => { + const minimalLegacySurvey: LegacySurvey = { + versionId: '1.0.0', + surveyDefinition: { + key: 'root', + items: [] + } as LegacySurveyGroupItem + }; + + const newSurvey = convertLegacyToNewSurvey(minimalLegacySurvey); + + expect(newSurvey.schemaVersion).toBe(1); + }); + + test('should handle survey props with multiple language translations', () => { + const legacySurvey: LegacySurvey = { + versionId: '1.0.0', + props: { + name: [{ + code: 'en', + parts: [{ str: 'English Survey Name', dtype: 'str' }], + resolvedText: 'English Survey Name' + }, { + code: 'es', + parts: [{ str: 'Nombre de Encuesta en Español', dtype: 'str' }], + resolvedText: 'Nombre de Encuesta en Español' + }], + description: [{ + code: 'en', + parts: [{ str: 'English Description', dtype: 'str' }], + resolvedText: 'English Description' + }, { + code: 'es', + parts: [{ str: 'Descripción en Español', dtype: 'str' }], + resolvedText: 'Descripción en Español' + }] + }, + surveyDefinition: { + key: 'root', + items: [] + } as LegacySurveyGroupItem + }; + + const newSurvey = convertLegacyToNewSurvey(legacySurvey); + + // Check that props translations were extracted correctly + expect(newSurvey.props?.translations).toBeDefined(); + expect(newSurvey.props?.translations!['en']).toEqual({ + name: 'English Survey Name', + description: 'English Description' + }); + expect(newSurvey.props?.translations!['es']).toEqual({ + name: 'Nombre de Encuesta en Español', + description: 'Descripción en Español' + }); + + // Check that content keys were set correctly + expect(newSurvey.props?.name?.key).toBe('English Survey Name'); + expect(newSurvey.props?.description?.key).toBe('English Description'); + + // Test conversion back to legacy + const backToLegacy = convertNewToLegacySurvey(newSurvey); + expect(backToLegacy.props?.name).toHaveLength(2); + expect(backToLegacy.props?.description).toHaveLength(2); + + // Verify both languages are preserved + const nameEn = backToLegacy.props?.name?.find(n => n.code === 'en'); + const nameEs = backToLegacy.props?.name?.find(n => n.code === 'es'); + expect(nameEn?.resolvedText).toBe('English Survey Name'); + expect(nameEs?.resolvedText).toBe('Nombre de Encuesta en Español'); + }); +}); + */ \ No newline at end of file diff --git a/src/__tests__/page-model.test.ts b/src/__tests__/page-model.test.ts deleted file mode 100644 index e380379..0000000 --- a/src/__tests__/page-model.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { Survey, SurveyGroupItem } from "../data_types"; -import { SurveyEngineCore } from "../engine"; - - -describe('testing max item per page', () => { - const testSurvey: Survey = { - versionId: 'wfdojsdfpo', - surveyDefinition: { - key: "root", - items: [ - { key: 'root.1', }, - { key: 'root.2', }, - { key: 'root.3', }, - { key: 'root.4', }, - { key: 'root.5', }, - { key: 'root.6', }, - { key: 'root.7', }, - { key: 'root.8', }, - { key: 'root.9', }, - { key: 'root.10', }, - ], - }, - maxItemsPerPage: { large: 1, small: 1 }, - }; - - - test('max one item', () => { - const surveyE = new SurveyEngineCore( - testSurvey, - ); - const pages = surveyE.getSurveyPages() - expect(pages).toHaveLength(10); - pages.forEach(page => { - expect(page).toHaveLength(1); - }) - }) - - test('max four items', () => { - testSurvey.maxItemsPerPage = { large: 4, small: 4 }; - const surveyE = new SurveyEngineCore( - testSurvey, - ); - const pages = surveyE.getSurveyPages() - expect(pages).toHaveLength(3); - pages.forEach(page => { - expect(page.length == 4 || page.length == 2).toBeTruthy(); - }) - }) - - test('different large and small setting', () => { - testSurvey.maxItemsPerPage = { large: 4, small: 2 }; - const surveyE = new SurveyEngineCore( - testSurvey, - ); - const pagesL = surveyE.getSurveyPages('large') - expect(pagesL).toHaveLength(3); - const pagesS = surveyE.getSurveyPages('small') - expect(pagesS).toHaveLength(5); - }) - - test('more than survey items present', () => { - testSurvey.maxItemsPerPage = { large: 41, small: 22 }; - const surveyE = new SurveyEngineCore( - testSurvey, - ); - const pagesL = surveyE.getSurveyPages('large') - expect(pagesL).toHaveLength(1); - const pagesS = surveyE.getSurveyPages('small') - expect(pagesS).toHaveLength(1); - }) -}) - -describe('testing pageBreak items', () => { - test('test page break item after each other (empty page)', () => { - const testSurvey: Survey = { - versionId: 'wfdojsdfpo', - surveyDefinition: { - key: "root", - items: [ - { key: 'root.1', type: 'pageBreak' }, - { key: 'root.2', type: 'pageBreak' }, - { key: 'root.3', type: 'pageBreak' }, - { key: 'root.4', type: 'pageBreak' }, - { key: 'root.5', type: 'pageBreak' }, - { key: 'root.6', type: 'pageBreak' }, - { key: 'root.7', type: 'pageBreak' }, - { key: 'root.8', type: 'pageBreak' }, - { key: 'root.9', type: 'pageBreak' }, - { key: 'root.10', type: 'pageBreak' }, - ], - } - } - - const surveyE = new SurveyEngineCore( - testSurvey, - ); - const pages = surveyE.getSurveyPages() - expect(pages).toHaveLength(0); - - expect(surveyE.getResponses()).toHaveLength(0); - }) - - test('test page break item typical usecase', () => { - const testSurvey: Survey = { - versionId: 'wfdojsdfpo', - surveyDefinition: { - key: "root", - items: [ - { key: 'root.1', follows: ['root'] }, - { key: 'root.2', follows: ['root.1'] }, - { key: 'root.3', follows: ['root.2'] }, - { key: 'root.4', follows: ['root.3'] }, - { key: 'root.5', follows: ['root.4'] }, - { key: 'root.6', follows: ['root.5'], type: 'pageBreak' }, - { key: 'root.7', follows: ['root.6'] }, - { key: 'root.8', follows: ['root.7'] }, - { key: 'root.9', type: 'pageBreak', follows: ['root.8'] }, - { key: 'root.10', follows: ['root.9'] }, - ], - } - } - const surveyE = new SurveyEngineCore( - testSurvey, - ); - const pages = surveyE.getSurveyPages() - expect(pages).toHaveLength(3); - - expect(surveyE.getResponses()).toHaveLength(8); - }) -}) - -describe('testing max item per page together with page break', () => { - const surveyDef: SurveyGroupItem = { - key: "root", - items: [ - { key: 'root.1', follows: ['root'] }, - { key: 'root.2', follows: ['root.1'] }, - { key: 'root.3', follows: ['root.2'] }, - { key: 'root.4', follows: ['root.3'] }, - { key: 'root.5', follows: ['root.4'] }, - { key: 'root.6', follows: ['root.5'], type: 'pageBreak' }, - { key: 'root.7', follows: ['root.6'] }, - { key: 'root.8', follows: ['root.7'] }, - { key: 'root.9', type: 'pageBreak', follows: ['root.8'] }, - { key: 'root.10', follows: ['root.9'] }, - ], - }; - - test('max one item per page together with pagebreaks', () => { - const testSurvey: Survey = { - versionId: 'wfdojsdfpo', - surveyDefinition: surveyDef, - maxItemsPerPage: { large: 1, small: 1 }, - } - const surveyE = new SurveyEngineCore( - testSurvey, - ); - - const pages = surveyE.getSurveyPages() - expect(pages).toHaveLength(8); - expect(surveyE.getResponses()).toHaveLength(8); - }) - - test('max four items per page together with pagebreak', () => { - const testSurvey: Survey = { - versionId: 'wfdojsdfpo', - surveyDefinition: surveyDef, - maxItemsPerPage: { large: 4, small: 4 }, - } - const surveyE = new SurveyEngineCore( - testSurvey, - ); - - const pages = surveyE.getSurveyPages() - expect(pages).toHaveLength(4); - }) -}) diff --git a/src/__tests__/prefill.test.ts b/src/__tests__/prefill.test.ts deleted file mode 100644 index 9c88c76..0000000 --- a/src/__tests__/prefill.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Survey, SurveySingleItemResponse } from "../data_types"; -import { SurveyEngineCore } from "../engine"; - -test('testing survey initialized with prefills', () => { - const testSurvey: Survey = { - - versionId: 'wfdojsdfpo', - surveyDefinition: { - key: "root", - items: [ - { key: 'root.1', follows: ['root'] }, - { key: 'root.2', follows: ['root.1'] }, - { - key: 'root.G1', items: [ - { key: 'root.G1.3', follows: ['root.G1'] }, - { key: 'root.G1.4', follows: ['root.G1.3'] }, - { key: 'root.G1.5', }, - ] - }, - ], - } - }; - - const prefills: SurveySingleItemResponse[] = [ - { key: 'root.1', response: { key: '1' } }, - { key: 'root.G1.4', response: { key: '2' } }, - { key: 'root.G1.5', response: { key: '3' } }, - ] - - const surveyE = new SurveyEngineCore( - testSurvey, - undefined, - prefills - ); - - const responses = surveyE.getResponses(); - - expect(responses).toHaveLength(5); - expect(responses[0].response?.key).toEqual('1'); - expect(responses[3].response?.key).toEqual('2'); - expect(responses[4].response?.key).toEqual('3'); - -}) diff --git a/src/__tests__/render-item-components.test.ts b/src/__tests__/render-item-components.test.ts deleted file mode 100644 index 51a032a..0000000 --- a/src/__tests__/render-item-components.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { SurveySingleItem, SurveyGroupItem, SurveyContext, ItemComponent, LocalizedString, Survey, ItemGroupComponent } from '../data_types'; -import { SurveyEngineCore } from '../engine'; - -// ---------- Create a test survey definition ---------------- -const testItem: SurveySingleItem = { - key: '0.1', - validations: [], - components: { - role: 'root', - items: [ - { - key: '1', - role: 'text', - content: [ - { - code: 'en', - parts: [ - { - str: 'test' - }, - { - dtype: 'exp', - exp: { - name: 'getAttribute', - data: [ - { dtype: 'exp', exp: { name: 'getContext' } }, - { str: 'mode' } - ] - } - }, - ] - }, - ], - description: [ - { - code: 'en', - parts: [ - { - str: 'test2' - }, - { - dtype: 'exp', - exp: { - name: 'getAttribute', - data: [ - { dtype: 'exp', exp: { name: 'getContext' } }, - { str: 'mode' } - ] - } - }, - ] - } - ], - }, - { - key: '2', - role: 'text', - disabled: { - name: 'eq', - data: [ - { str: 'test' }, - { - dtype: 'exp', exp: { - name: 'getAttribute', - data: [ - { dtype: 'exp', exp: { name: 'getContext' } }, - { str: 'mode' } - ] - } - } - ] - }, - displayCondition: { - name: 'eq', - data: [ - { str: 'test' }, - { - dtype: 'exp', exp: { - name: 'getAttribute', - data: [ - { dtype: 'exp', exp: { name: 'getContext' } }, - { str: 'mode' } - ] - } - } - ] - } - }, - { - key: '3', - role: 'text', - disabled: { - name: 'eq', - data: [ - { str: 'test2' }, - { - dtype: 'exp', exp: { - name: 'getAttribute', - data: [ - { dtype: 'exp', exp: { name: 'getContext' } }, - { str: 'mode' } - ] - } - } - ] - }, - displayCondition: { - name: 'eq', - data: [ - { str: 'test2' }, - { - dtype: 'exp', exp: { - name: 'getAttribute', - data: [ - { dtype: 'exp', exp: { name: 'getContext' } }, - { str: 'mode' } - ] - } - } - ] - } - }, - { - key: '4', - role: 'numberInput', - properties: { - min: { - dtype: 'num', - num: -5, - }, - max: { - dtype: 'exp', exp: { - name: 'getAttribute', - returnType: 'float', - data: [ - { dtype: 'exp', exp: { name: 'getContext' } }, - { str: 'mode' } - ] - } - } - - } - } - ] - } -} - -const testItem2: SurveySingleItem = { - key: '0.2', - validations: [], - components: { - role: 'root', - items: [ - { - key: '1', - role: 'title', - items: [ - { - key: '1', - role: 'text', - content: [ - { - code: 'en', - parts: [ - { - str: 'test' - }, - ] - }, - ] - }, - { - key: '2', - role: 'dateDisplay', - content: [ - { - code: 'en', - parts: [ - { - dtype: 'exp', - exp: { - name: 'timestampWithOffset', - data: [ - { dtype: 'num', num: -15 }, - ] - } - }, - ] - }, - ] - } - ], - }, - ] - } -} - -const testSurvey: Survey = { - - versionId: 'wfdojsdfpo', - surveyDefinition: { - key: '0', - items: [ - testItem, - testItem2, - ] - } -} - -test('testing item component disabled', () => { - const context: SurveyContext = { - mode: 'test' - }; - const surveyE = new SurveyEngineCore( - testSurvey, - context - ); - - const renderedSurvey = surveyE.getRenderedSurvey(); - const testComponent = (renderedSurvey.items.find(item => item.key === '0.1') as SurveySingleItem).components?.items.find(comp => comp.key === '2'); - if (!testComponent) { - throw Error('object is undefined') - } - const testComponent2 = (renderedSurvey.items.find(item => item.key === '0.1') as SurveySingleItem).components?.items.find(comp => comp.key === '3'); - if (!testComponent2) { - throw Error('object is undefined') - } - - expect(testComponent.disabled).toBeTruthy(); - expect(testComponent2.disabled).toBeFalsy(); -}); - -test('testing item component displayCondition', () => { - const context: SurveyContext = { - mode: 'test' - }; - const surveyE = new SurveyEngineCore( - testSurvey, - context - ); - - const renderedSurvey = surveyE.getRenderedSurvey(); - const testComponent = (renderedSurvey.items.find(item => item.key === '0.1') as SurveySingleItem).components?.items.find(comp => comp.key === '2'); - if (!testComponent) { - throw Error('object is undefined') - } - const testComponent2 = (renderedSurvey.items.find(item => item.key === '0.1') as SurveySingleItem).components?.items.find(comp => comp.key === '3'); - if (!testComponent2) { - throw Error('object is undefined') - } - - expect(testComponent.displayCondition).toBeTruthy(); - expect(testComponent2.displayCondition).toBeFalsy(); -}); - -test('testing item component properties', () => { - const context: SurveyContext = { - mode: '4.5' - }; - const surveyE = new SurveyEngineCore( - testSurvey, - context - ); - - const renderedSurvey = surveyE.getRenderedSurvey(); - const testComponent = (renderedSurvey.items.find(item => item.key === '0.1') as SurveySingleItem).components?.items.find(comp => comp.key === '4'); - if (!testComponent || !testComponent.properties) { - throw Error('object is undefined') - } - - - expect(testComponent.properties.min).toEqual(-5); - expect(testComponent.properties.max).toEqual(4.5); -}); - -test('testing item component content', () => { - const context: SurveyContext = { - mode: 'test' - }; - const surveyE = new SurveyEngineCore( - testSurvey, - context - ); - - const renderedSurvey = surveyE.getRenderedSurvey(); - const testComponent = (renderedSurvey.items.find(item => item.key === '0.1') as SurveySingleItem).components?.items.find(comp => comp.key === '1'); - if (!testComponent || !testComponent.content || !testComponent.content[0]) { - throw Error('object is undefined') - } - const content = (testComponent.content[0] as LocalizedString).parts.join(''); - expect(content).toEqual('testtest'); -}); - -test('testing item component description', () => { - const context: SurveyContext = { - mode: 'test' - }; - const surveyE = new SurveyEngineCore( - testSurvey, - context - ); - - let renderedSurvey = surveyE.getRenderedSurvey(); - surveyE.setResponse('0.1', undefined); - renderedSurvey = surveyE.getRenderedSurvey(); - const testComponent = (renderedSurvey.items.find(item => item.key === '0.1') as SurveySingleItem).components?.items.find(comp => comp.key === '1'); - if (!testComponent || !testComponent.description || !testComponent.description[0]) { - throw Error('object is undefined') - } - const content = (testComponent.description[0] as LocalizedString).parts.join(''); - expect(content).toEqual('test2test'); -}); - -test('testing item component with expressions', () => { - const context: SurveyContext = { - mode: 'test' - }; - const surveyE = new SurveyEngineCore( - testSurvey, - context - ); - - const renderedSurvey = surveyE.getRenderedSurvey(); - const testComponent = (renderedSurvey.items.find(item => item.key === '0.2') as SurveySingleItem).components?.items.find(comp => comp.key === '1'); - //console.log(JSON.stringify(testComponent, undefined, 2)) - - if (!testComponent) { - throw Error('object is undefined') - } - - const items = (testComponent as ItemGroupComponent).items; - if (!items || items.length < 2) { - throw Error('items not found found') - } - - const content = items[1].content; - if (!content || content.length < 1) { - throw Error('content not found found') - } - - const parts = (content[0] as LocalizedString).parts; - if (!parts || parts.length < 1) { - throw Error('content not found found') - } - - - expect(typeof (parts[0])).toEqual('number'); -}); diff --git a/src/__tests__/selection-method.test.ts b/src/__tests__/selection-method.test.ts deleted file mode 100644 index c989513..0000000 --- a/src/__tests__/selection-method.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { SelectionMethod } from "../selection-method"; -import { Survey } from "../data_types"; -import { SurveyEngineCore } from "../engine"; -import { flattenSurveyItemTree } from "../utils"; - -describe('testing selection methods', () => { - test('without method definition', () => { - const items = [ - { key: 'q1' }, - { key: 'q2' }, - { key: 'q3' }, - ]; - - const item = SelectionMethod.pickAnItem(items); - expect(item).toBeDefined(); - console.log('selected item is: ' + item.key); - }); - - test('with uniform random selection', () => { - const items = [ - { key: 'q1' }, - { key: 'q2' }, - { key: 'q3' }, - ]; - - const item = SelectionMethod.pickAnItem(items, { name: 'uniform' }); - expect(item).toBeDefined(); - console.log('selected item is: ' + item.key); - }); - - test('with highestPriority selection - missing priorities', () => { - const items = [ - { key: 'q1' }, - { key: 'q2' }, - { key: 'q3' }, - ]; - - const item = SelectionMethod.pickAnItem(items, { name: 'highestPriority' }); - expect(item).toBeDefined(); - expect(item.key).toBe('q1'); - console.log('selected item is: ' + item.key); - }); - - test('with highestPriority selection', () => { - const items = [ - { key: 'q1', priority: 2 }, - { key: 'q2', priority: 3 }, - { key: 'q3', priority: 1 }, - { key: 'q4' }, - ]; - - const item = SelectionMethod.pickAnItem(items, { name: 'highestPriority' }); - expect(item).toBeDefined(); - expect(item.key).toBe('q2'); - console.log('selected item is: ' + item.key); - }); - - test('with exponential distribution selection missing data argument', () => { - const items = [ - { key: 'q1', priority: 2 }, - { key: 'q2', priority: 3 }, - { key: 'q3', priority: 1 }, - { key: 'q4' }, - ]; - - const item = SelectionMethod.pickAnItem(items, { name: 'exponential' }); - expect(item).toBeUndefined(); - }); - - test('with exponential distribution selection with wrong data argmument type', () => { - const items = [ - { key: 'q1', priority: 2 }, - { key: 'q2', priority: 3 }, - { key: 'q3', priority: 1 }, - { key: 'q4' }, - ]; - - const item = SelectionMethod.pickAnItem(items, { name: 'exponential', data: [{ dtype: 'str', str: '2' }] }); - expect(item).toBeUndefined(); - }); - - test('with exponential distribution selection', () => { - const items = [ - { key: 'q1', priority: 2 }, - { key: 'q2', priority: 3 }, - { key: 'q3', priority: 1 }, - { key: 'q4' }, - ]; - - const item = SelectionMethod.pickAnItem(items, { name: 'exponential', data: [{ dtype: 'num', num: 0.5 }] }); - expect(item).toBeDefined(); - console.log('selected item is: ' + item.key); - }); -}); - -test('without sequential selection (spec. use case)', () => { - const testSurvey: Survey = { - - versionId: 'wfdojsdfpo', - surveyDefinition: { - key: "root", - selectionMethod: { name: 'sequential' }, - items: [ - { key: 'root.1', }, - { - key: 'root.2', condition: { - name: 'isDefined', - data: [ - { - dtype: 'exp', - exp: { - name: 'getResponseItem', - data: [ - { - str: 'root.1' - }, - { - str: '1' - } - ] - } - } - ] - }, - }, - { - key: 'root.G1', - selectionMethod: { name: 'sequential' }, - items: [ - { key: 'root.G1.3', }, - { - key: 'root.G1.G2', - condition: { - name: 'isDefined', - data: [ - { - dtype: 'exp', - exp: { - name: 'getResponseItem', - data: [ - { - str: 'root.G1.3' - }, - { - str: '1' - } - ] - } - } - ] - }, - selectionMethod: { name: 'sequential' }, - items: [ - { key: 'root.G1.G2.1', }, - { key: 'root.G1.G2.2', }, - ] - }, - { key: 'root.G1.5', }, - ] - }, - ], - } - }; - - const surveyE = new SurveyEngineCore( - testSurvey, - ); - - const renderedItems = flattenSurveyItemTree(surveyE.getRenderedSurvey()); - - surveyE.setResponse('root.1', { key: '1' }); - surveyE.setResponse('root.G1.3', { key: '1' }); - const renderedItems2 = flattenSurveyItemTree(surveyE.getRenderedSurvey()); - - surveyE.setResponse('root.1', { key: '2' }); - surveyE.setResponse('root.G1.3', { key: '2' }); - const renderedItems3 = flattenSurveyItemTree(surveyE.getRenderedSurvey()); - - expect(renderedItems).toHaveLength(3); - expect(renderedItems2).toHaveLength(6); - expect(renderedItems3).toHaveLength(3); - - expect(renderedItems[0].key).toEqual('root.1'); - expect(renderedItems[1].key).toEqual('root.G1.3'); - expect(renderedItems[2].key).toEqual('root.G1.5'); - - expect(renderedItems2[0].key).toEqual('root.1'); - expect(renderedItems2[1].key).toEqual('root.2'); - expect(renderedItems2[2].key).toEqual('root.G1.3'); - expect(renderedItems2[3].key).toEqual('root.G1.G2.1'); - expect(renderedItems2[4].key).toEqual('root.G1.G2.2'); - expect(renderedItems2[5].key).toEqual('root.G1.5'); - - expect(renderedItems3[0].key).toEqual('root.1'); - expect(renderedItems3[1].key).toEqual('root.G1.3'); - expect(renderedItems3[2].key).toEqual('root.G1.5'); -}) diff --git a/src/__tests__/survey-editor-component-key-change.test.ts b/src/__tests__/survey-editor-component-key-change.test.ts new file mode 100644 index 0000000..5e471c0 --- /dev/null +++ b/src/__tests__/survey-editor-component-key-change.test.ts @@ -0,0 +1,585 @@ +import { Survey } from '../survey/survey'; +import { SurveyEditor } from '../survey-editor/survey-editor'; +import { DisplayItem, GroupItem, SingleChoiceQuestionItem, SurveyItemType } from '../survey/items'; +import { SurveyItemTranslations } from '../survey/utils'; +import { Content, ContentType } from '../survey/utils/content'; +import { ScgMcgOption, TextComponent } from '../survey/components'; +import { SingleChoiceQuestionEditor, SurveyItemEditor } from '../survey-editor/survey-item-editors'; +import { ConstExpression } from '../expressions'; +import { DisplayComponentEditor, ScgMcgOptionEditor } from '../survey-editor/component-editor'; + +// Helper function to create a test survey +const createTestSurvey = (surveyKey: string = 'test-survey'): Survey => { + const survey = new Survey(surveyKey); + + // Add a sub-group to the root + const subGroup = new GroupItem(`${surveyKey}.page1`); + survey.surveyItems[`${surveyKey}.page1`] = subGroup; + + // Add the sub-group to the root group's items + const rootGroup = survey.surveyItems[surveyKey] as GroupItem; + rootGroup.items = [`${surveyKey}.page1`]; + + return survey; +}; + +// Helper function to create test translations +const createTestTranslations = (): SurveyItemTranslations => { + const translations = new SurveyItemTranslations(); + const testContent: Content = { + type: ContentType.md, + content: 'Test content' + }; + translations.setContent('en', 'title', testContent); + return translations; +}; + +// Test implementation of SurveyItemEditor for testing purposes +class TestSurveyItemEditor extends SurveyItemEditor { + convertToType(type: SurveyItemType): void { + // Implementation not needed for this test + } +} + +describe('Survey Editor Component Key Change Tests', () => { + let survey: Survey; + let editor: SurveyEditor; + + beforeEach(() => { + survey = createTestSurvey(); + editor = new SurveyEditor(survey); + }); + + describe('Survey Editor Level - onComponentKeyChanged', () => { + + describe('Simple component in display item', () => { + test('should change key of text component in display item', () => { + // Add a display item with text component + const displayItem = new DisplayItem('test-survey.page1.display1'); + const textComponent = new TextComponent('title', undefined, displayItem.key.fullKey); + displayItem.components = [textComponent]; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem, testTranslations); + + // Verify initial component key + expect(textComponent.key.fullKey).toBe('title'); + expect(textComponent.key.componentKey).toBe('title'); + + // Change component key through survey editor + editor.onComponentKeyChanged('test-survey.page1.display1', 'title', 'main-title'); + + // Verify component key is updated + expect(textComponent.key.fullKey).toBe('main-title'); + expect(textComponent.key.componentKey).toBe('main-title'); + expect(displayItem.components![0]).toBe(textComponent); + }); + + test('should update translations when changing component key', () => { + // Add a display item with text component + const displayItem = new DisplayItem('test-survey.page1.display1'); + const textComponent = new TextComponent('title', undefined, displayItem.key.fullKey); + displayItem.components = [textComponent]; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem, testTranslations); + + // Add specific translation for the component + testTranslations.setContent('en', 'title', { type: ContentType.md, content: 'Original Title' }); + editor.updateItemTranslations('test-survey.page1.display1', testTranslations); + + // Change component key + editor.onComponentKeyChanged('test-survey.page1.display1', 'title', 'main-title'); + + // Verify translations are updated + const updatedTranslations = editor.survey.getItemTranslations('test-survey.page1.display1'); + expect(updatedTranslations).toBeDefined(); + expect(updatedTranslations!.getContent('en', 'main-title')).toEqual({ type: ContentType.md, content: 'Original Title' }); + expect(updatedTranslations!.getContent('en', 'title')).toBeUndefined(); + }); + + test('should commit changes when changing component key', () => { + // Add a display item with text component + const displayItem = new DisplayItem('test-survey.page1.display1'); + const textComponent = new TextComponent('title', undefined, displayItem.key.fullKey); + displayItem.components = [textComponent]; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem, testTranslations); + + // Change component key + editor.onComponentKeyChanged('test-survey.page1.display1', 'title', 'main-title'); + + // Verify changes are committed + expect(editor.hasUncommittedChanges).toBe(false); + expect(editor.canUndo()).toBe(true); + expect(editor.getUndoDescription()).toBe('Renamed component title to main-title in test-survey.page1.display1'); + }); + + test('should throw error when item does not exist', () => { + expect(() => { + editor.onComponentKeyChanged('non-existent-item', 'title', 'new-title'); + }).toThrow("Item with key 'non-existent-item' not found"); + }); + }); + + describe('Component within question item', () => { + test('should change key of title component in question header', () => { + // Add a single choice question with title + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const titleComponent = new TextComponent('title', undefined, questionItem.key.fullKey); + questionItem.header = { title: titleComponent }; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + + // Verify initial component key + expect(titleComponent.key.fullKey).toBe('title'); + + // Change component key + editor.onComponentKeyChanged('test-survey.page1.question1', 'title', 'question-title'); + + // Verify component key is updated + expect(titleComponent.key.fullKey).toBe('question-title'); + expect(questionItem.header?.title).toBe(titleComponent); + }); + + test('should change key of subtitle component in question header', () => { + // Add a single choice question with subtitle + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const subtitleComponent = new TextComponent('subtitle', undefined, questionItem.key.fullKey); + questionItem.header = { subtitle: subtitleComponent }; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + + // Change component key + editor.onComponentKeyChanged('test-survey.page1.question1', 'subtitle', 'question-subtitle'); + + // Verify component key is updated + expect(subtitleComponent.key.fullKey).toBe('question-subtitle'); + expect(questionItem.header?.subtitle).toBe(subtitleComponent); + }); + + test('should change key of help popover component in question header', () => { + // Add a single choice question with help popover + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const helpComponent = new TextComponent('help', undefined, questionItem.key.fullKey); + questionItem.header = { helpPopover: helpComponent }; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + + // Change component key + editor.onComponentKeyChanged('test-survey.page1.question1', 'help', 'help-popover'); + + // Verify component key is updated + expect(helpComponent.key.fullKey).toBe('help-popover'); + expect(questionItem.header?.helpPopover).toBe(helpComponent); + }); + + test('should change key of component in question body top content', () => { + // Add a single choice question with body content + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const bodyComponent = new TextComponent('intro', undefined, questionItem.key.fullKey); + questionItem.body = { topContent: [bodyComponent] }; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + + // Change component key + editor.onComponentKeyChanged('test-survey.page1.question1', 'intro', 'introduction'); + + // Verify component key is updated + expect(bodyComponent.key.fullKey).toBe('introduction'); + expect(questionItem.body?.topContent![0]).toBe(bodyComponent); + }); + + test('should change key of component in question body bottom content', () => { + // Add a single choice question with body content + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const bodyComponent = new TextComponent('note', undefined, questionItem.key.fullKey); + questionItem.body = { bottomContent: [bodyComponent] }; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + + // Change component key + editor.onComponentKeyChanged('test-survey.page1.question1', 'note', 'bottom-note'); + + // Verify component key is updated + expect(bodyComponent.key.fullKey).toBe('bottom-note'); + expect(questionItem.body?.bottomContent![0]).toBe(bodyComponent); + }); + + test('should change key of footer component in question', () => { + // Add a single choice question with footer + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const footerComponent = new TextComponent('footer', undefined, questionItem.key.fullKey); + questionItem.footer = footerComponent; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + + // Change component key + editor.onComponentKeyChanged('test-survey.page1.question1', 'footer', 'question-footer'); + + // Verify component key is updated + expect(footerComponent.key.fullKey).toBe('question-footer'); + expect(questionItem.footer).toBe(footerComponent); + }); + + test('should update display and disabled conditions when changing component key', () => { + // Add a single choice question with conditions + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const titleComponent = new TextComponent('title', undefined, questionItem.key.fullKey); + questionItem.header = { title: titleComponent }; + + // Add display and disabled conditions for the component using proper Expression objects + questionItem.displayConditions = { + components: { + 'title': new ConstExpression(true) + } + }; + questionItem.disabledConditions = { + components: { + 'title': new ConstExpression(false) + } + }; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + + // Change component key + editor.onComponentKeyChanged('test-survey.page1.question1', 'title', 'question-title'); + + // Verify conditions are updated + expect(questionItem.displayConditions?.components?.['question-title']).toBeDefined(); + expect(questionItem.displayConditions?.components?.['title']).toBeUndefined(); + expect(questionItem.disabledConditions?.components?.['question-title']).toBeDefined(); + expect(questionItem.disabledConditions?.components?.['title']).toBeUndefined(); + }); + }); + + describe('Single choice response config and options', () => { + test('should change key of single choice response config component', () => { + // Add a single choice question + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + + // Get initial response config key + const initialResponseConfigKey = questionItem.responseConfig.key.fullKey; + expect(initialResponseConfigKey).toBe('scg'); + + // Change response config key + editor.onComponentKeyChanged('test-survey.page1.question1', 'scg', 'choice-config'); + + // Verify response config key is updated + expect(questionItem.responseConfig.key.fullKey).toBe('choice-config'); + expect(questionItem.responseConfig.key.componentKey).toBe('choice-config'); + }); + + test('should change key of single choice option', () => { + // Add a single choice question with options + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const option1 = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + const option2 = new ScgMcgOption('option2', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + questionItem.responseConfig.items = [option1, option2]; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + + // Verify initial option key + expect(option1.key.fullKey).toBe('scg.option1'); + expect(option1.key.componentKey).toBe('option1'); + + // Change option key - note: we change the full key in the system, but the component updates its internal componentKey + editor.onComponentKeyChanged('test-survey.page1.question1', 'scg.option1', 'scg.choice-a'); + + // Verify option key is updated + expect(option1.key.fullKey).toBe('scg.choice-a'); + expect(option1.key.componentKey).toBe('choice-a'); + expect(questionItem.responseConfig.items[0]).toBe(option1); + }); + + test('should update parent component key for options when response config key changes', () => { + // Add a single choice question with options + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const option1 = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + const option2 = new ScgMcgOption('option2', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + questionItem.responseConfig.items = [option1, option2]; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + + // Verify initial option parent keys + expect(option1.key.parentFullKey).toBe('scg'); + expect(option2.key.parentFullKey).toBe('scg'); + + // Change response config key + editor.onComponentKeyChanged('test-survey.page1.question1', 'scg', 'choice-config'); + + // Verify option parent keys are updated + expect(option1.key.parentFullKey).toBe('choice-config'); + expect(option2.key.parentFullKey).toBe('choice-config'); + expect(option1.key.fullKey).toBe('choice-config.option1'); + expect(option2.key.fullKey).toBe('choice-config.option2'); + }); + }); + }); + + describe('Item Editor Level - changeComponentKey', () => { + + test('should change component key through item editor', () => { + // Add a display item with text component + const displayItem = new DisplayItem('test-survey.page1.display1'); + const textComponent = new TextComponent('title', undefined, displayItem.key.fullKey); + displayItem.components = [textComponent]; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem, testTranslations); + + // Create item editor + const itemEditor = new TestSurveyItemEditor(editor, 'test-survey.page1.display1', SurveyItemType.Display); + + // Change component key through item editor + itemEditor.changeComponentKey('title', 'main-title'); + + // Verify component key is updated + expect(textComponent.key.fullKey).toBe('main-title'); + expect(textComponent.key.componentKey).toBe('main-title'); + }); + + test('should change component key in single choice question through question editor', () => { + // Add a single choice question with title + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const titleComponent = new TextComponent('title', undefined, questionItem.key.fullKey); + questionItem.header = { title: titleComponent }; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + + // Create question editor + const questionEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + + // Change component key through question editor + questionEditor.changeComponentKey('title', 'question-title'); + + // Verify component key is updated + expect(titleComponent.key.fullKey).toBe('question-title'); + expect(titleComponent.key.componentKey).toBe('question-title'); + }); + + test('should change option key through question editor', () => { + // Add a single choice question with options + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const option1 = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + questionItem.responseConfig.items = [option1]; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + + // Create question editor + const questionEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + + // Change option key through question editor + questionEditor.changeComponentKey('scg.option1', 'scg.choice-a'); + + // Verify option key is updated + expect(option1.key.fullKey).toBe('scg.choice-a'); + expect(option1.key.componentKey).toBe('choice-a'); + }); + }); + + describe('Component Editor Level - changeKey', () => { + + test('should change key through DisplayComponentEditor with local key', () => { + // Add a display item with text component + const displayItem = new DisplayItem('test-survey.page1.display1'); + const textComponent = new TextComponent('title', undefined, displayItem.key.fullKey); + displayItem.components = [textComponent]; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem, testTranslations); + + // Create item editor and component editor + const itemEditor = new TestSurveyItemEditor(editor, 'test-survey.page1.display1', SurveyItemType.Display); + const componentEditor = new DisplayComponentEditor(itemEditor, textComponent); + + // Change key through component editor using local key (default behavior) + componentEditor.changeKey('main-title'); + + // Verify component key is updated + expect(textComponent.key.fullKey).toBe('main-title'); + expect(textComponent.key.componentKey).toBe('main-title'); + }); + + test('should change key through DisplayComponentEditor with full key', () => { + // Add a display item with text component + const displayItem = new DisplayItem('test-survey.page1.display1'); + const textComponent = new TextComponent('title', undefined, displayItem.key.fullKey); + displayItem.components = [textComponent]; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem, testTranslations); + + // Create item editor and component editor + const itemEditor = new TestSurveyItemEditor(editor, 'test-survey.page1.display1', SurveyItemType.Display); + const componentEditor = new DisplayComponentEditor(itemEditor, textComponent); + + // Change key through component editor using full key + componentEditor.changeKey('page-title', true); // isFullKey = true + + // Verify component key is updated + expect(textComponent.key.fullKey).toBe('page-title'); + expect(textComponent.key.componentKey).toBe('page-title'); + }); + + test('should change option key through ScgMcgOptionEditor with local key', () => { + // Add a single choice question with options + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const option1 = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + questionItem.responseConfig.items = [option1]; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + + // Create question editor and option editor + const questionEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + const optionEditor = new ScgMcgOptionEditor(questionEditor, option1); + + // Change key through option editor using local key (just the component part) + optionEditor.changeKey('choice-a'); // This should be expanded to 'scg.choice-a' + + // Verify option key is updated + expect(option1.key.fullKey).toBe('scg.choice-a'); + expect(option1.key.componentKey).toBe('choice-a'); + }); + + test('should change option key through ScgMcgOptionEditor with full key', () => { + // Add a single choice question with options + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const option1 = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + questionItem.responseConfig.items = [option1]; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + + // Create question editor and option editor + const questionEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + const optionEditor = new ScgMcgOptionEditor(questionEditor, option1); + + // Change key through option editor using full key + optionEditor.changeKey('scg.choice-b', true); // isFullKey = true + + // Verify option key is updated + expect(option1.key.fullKey).toBe('scg.choice-b'); + expect(option1.key.componentKey).toBe('choice-b'); + }); + + test('should change component key and update through entire editor hierarchy', () => { + // Add a single choice question with title + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const titleComponent = new TextComponent('title', undefined, questionItem.key.fullKey); + questionItem.header = { title: titleComponent }; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + + // Create editors + const questionEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + const titleEditor = questionEditor.title; + + // Change key through component editor + titleEditor.changeKey('question-title'); + + // Verify component key is updated at all levels + expect(titleComponent.key.fullKey).toBe('question-title'); + expect(titleComponent.key.componentKey).toBe('question-title'); + expect(questionItem.header?.title).toBe(titleComponent); + + // Verify changes are committed in survey editor + expect(editor.hasUncommittedChanges).toBe(false); + expect(editor.canUndo()).toBe(true); + }); + }); + + describe('Edge Cases and Error Handling', () => { + + test('should handle component key change when component does not exist', () => { + // Add a display item without components + const displayItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem, testTranslations); + + // Try to change non-existent component key - should not throw error + expect(() => { + editor.onComponentKeyChanged('test-survey.page1.display1', 'non-existent', 'new-key'); + }).not.toThrow(); + }); + + test('should handle changing component key to the same key', () => { + // Add a display item with text component + const displayItem = new DisplayItem('test-survey.page1.display1'); + const textComponent = new TextComponent('title', undefined, displayItem.key.fullKey); + displayItem.components = [textComponent]; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem, testTranslations); + + // Change component key to the same key + editor.onComponentKeyChanged('test-survey.page1.display1', 'title', 'title'); + + // Verify component key remains the same + expect(textComponent.key.fullKey).toBe('title'); + expect(textComponent.key.componentKey).toBe('title'); + }); + + test('should handle complex nested component key changes', () => { + // Add a single choice question with multiple components + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + + // Add title, subtitle, and footer + const titleComponent = new TextComponent('title', undefined, questionItem.key.fullKey); + const subtitleComponent = new TextComponent('subtitle', undefined, questionItem.key.fullKey); + const footerComponent = new TextComponent('footer', undefined, questionItem.key.fullKey); + + questionItem.header = { + title: titleComponent, + subtitle: subtitleComponent + }; + questionItem.footer = footerComponent; + + // Add options + const option1 = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + const option2 = new ScgMcgOption('option2', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + questionItem.responseConfig.items = [option1, option2]; + + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + + // Change multiple component keys using different methods + editor.onComponentKeyChanged('test-survey.page1.question1', 'title', 'main-title'); + editor.onComponentKeyChanged('test-survey.page1.question1', 'subtitle', 'sub-title'); + editor.onComponentKeyChanged('test-survey.page1.question1', 'footer', 'question-footer'); + + // For options, test both local and full key approaches via component editors + const questionEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + const option1Editor = new ScgMcgOptionEditor(questionEditor, option1); + const option2Editor = new ScgMcgOptionEditor(questionEditor, option2); + + // Option 1: use local key (default behavior) + option1Editor.changeKey('choice-a'); + // Option 2: use full key + option2Editor.changeKey('scg.choice-b', true); + + // Verify all components are updated + expect(titleComponent.key.fullKey).toBe('main-title'); + expect(subtitleComponent.key.fullKey).toBe('sub-title'); + expect(footerComponent.key.fullKey).toBe('question-footer'); + expect(option1.key.fullKey).toBe('scg.choice-a'); + expect(option2.key.fullKey).toBe('scg.choice-b'); + }); + }); +}); diff --git a/src/__tests__/survey-editor-on-item-key-changed.test.ts b/src/__tests__/survey-editor-on-item-key-changed.test.ts new file mode 100644 index 0000000..8d42a21 --- /dev/null +++ b/src/__tests__/survey-editor-on-item-key-changed.test.ts @@ -0,0 +1,462 @@ +import { Survey } from '../survey/survey'; +import { SurveyEditor } from '../survey-editor/survey-editor'; +import { DisplayItem, GroupItem, SingleChoiceQuestionItem } from '../survey/items'; +import { SurveyItemTranslations } from '../survey/utils'; +import { Content, ContentType } from '../survey/utils/content'; +import { TextComponent, ScgMcgOption } from '../survey/components'; +import { Expression, ConstExpression, ResponseVariableExpression, FunctionExpression, FunctionExpressionNames } from '../expressions'; +import { TemplateValueDefinition, TemplateDefTypes } from '../expressions/template-value'; +import { ExpectedValueType } from '../survey/utils/types'; + +// Helper function to create a test survey with nested structure +const createTestSurveyWithNestedItems = (surveyKey: string = 'test-survey'): Survey => { + const survey = new Survey(surveyKey); + + // Add a sub-group to the root + const subGroup = new GroupItem(`${surveyKey}.page1`); + survey.surveyItems[`${surveyKey}.page1`] = subGroup; + + // Add the sub-group to the root group's items + const rootGroup = survey.surveyItems[surveyKey] as GroupItem; + rootGroup.items = [`${surveyKey}.page1`]; + + return survey; +}; + +const enLocale = 'en'; +const deLocale = 'de'; + +// Helper function to create test translations +const createTestTranslations = (): SurveyItemTranslations => { + const translations = new SurveyItemTranslations(); + const testContent: Content = { + type: ContentType.md, + content: 'Test content' + }; + translations.setContent(enLocale, 'title', testContent); + translations.setContent(deLocale, 'title', { type: ContentType.md, content: 'Test Inhalt' }); + return translations; +}; + +// Helper function to create test expressions with references +const createExpressionWithReference = (itemKey: string): Expression => { + return new FunctionExpression( + FunctionExpressionNames.eq, + [ + new ResponseVariableExpression(`${itemKey}...get`), + new ConstExpression('option1') + ] + ); +}; + +// Helper function to create template value with reference +const createTemplateValueWithReference = (itemKey: string): TemplateValueDefinition => { + return { + type: TemplateDefTypes.Default, + returnType: ExpectedValueType.String, + expression: new ResponseVariableExpression(`${itemKey}...get`) + }; +}; + +describe('SurveyEditor onItemKeyChanged', () => { + let survey: Survey; + let editor: SurveyEditor; + + beforeEach(() => { + survey = createTestSurveyWithNestedItems(); + editor = new SurveyEditor(survey); + }); + + describe('Simple item key change', () => { + test('should change key of simple item', () => { + // Add a simple display item + const displayItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem, testTranslations); + + // Verify item exists with original key + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBe(displayItem); + + // Change the key + editor.onItemKeyChanged('test-survey.page1.display1', 'test-survey.page1.display1-renamed'); + + // Verify item exists with new key + expect(editor.survey.surveyItems['test-survey.page1.display1-renamed']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.display1-renamed']).toBe(displayItem); + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeUndefined(); + + // Verify item's internal key is updated + expect(displayItem.key.fullKey).toBe('test-survey.page1.display1-renamed'); + + // Verify parent's items array is updated + const parentGroup = editor.survey.surveyItems['test-survey.page1'] as GroupItem; + expect(parentGroup.items).toContain('test-survey.page1.display1-renamed'); + expect(parentGroup.items).not.toContain('test-survey.page1.display1'); + }); + + test('should update component parent item key when item key changes', () => { + // Add a display item with components + const displayItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem, testTranslations); + + // Add a component to the display item + const textComponent = new TextComponent('title', undefined, 'test-survey.page1.display1'); + displayItem.components = [textComponent]; + + // Verify component's parent full key + expect(textComponent.key.parentItemKey.fullKey).toBe('test-survey.page1.display1'); + + // Change the item key + editor.onItemKeyChanged('test-survey.page1.display1', 'test-survey.page1.display1-renamed'); + + // Verify component's parent full key is updated + expect(textComponent.key.parentItemKey.fullKey).toBe('test-survey.page1.display1-renamed'); + expect(textComponent.key.fullKey).toBe('title'); + }); + }); + + describe('Group with nested items key change', () => { + test('should change key of group with nested items and update all nested component parent keys', () => { + // Add a group with nested items + const groupItem = new GroupItem('test-survey.page1.group1'); + const groupTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, groupItem, groupTranslations); + + // Add a single choice question inside the group + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.group1.question1'); + const questionTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1.group1' }, questionItem, questionTranslations); + + // Add components to the question + const titleComponent = new TextComponent('title', undefined, 'test-survey.page1.group1.question1'); + questionItem.header = { title: titleComponent }; + + // Add some options for testing + const option1 = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + const option2 = new ScgMcgOption('option2', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + questionItem.responseConfig.items = [option1, option2]; + + // Verify initial component keys + expect(titleComponent.key.parentItemKey.fullKey).toBe('test-survey.page1.group1.question1'); + expect(questionItem.responseConfig.items).toHaveLength(2); + expect(option1.key.parentItemKey.fullKey).toBe('test-survey.page1.group1.question1'); + expect(option2.key.parentItemKey.fullKey).toBe('test-survey.page1.group1.question1'); + + // Change the group key + editor.onItemKeyChanged('test-survey.page1.group1', 'test-survey.page1.group1-renamed'); + + // Verify group key is updated + expect(editor.survey.surveyItems['test-survey.page1.group1-renamed']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.group1']).toBeUndefined(); + + // Verify nested question key is updated + expect(editor.survey.surveyItems['test-survey.page1.group1-renamed.question1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.group1.question1']).toBeUndefined(); + + // Verify nested item's parent key is updated + const renamedQuestionItem = editor.survey.surveyItems['test-survey.page1.group1-renamed.question1']; + expect(renamedQuestionItem.key.parentFullKey).toBe('test-survey.page1.group1-renamed'); + + // Verify all component parent keys are updated + expect(titleComponent.key.parentItemKey.fullKey).toBe('test-survey.page1.group1-renamed.question1'); + expect(option1.key.parentItemKey.fullKey).toBe('test-survey.page1.group1-renamed.question1'); + expect(option2.key.parentItemKey.fullKey).toBe('test-survey.page1.group1-renamed.question1'); + + // Verify parent's items array is updated + const parentGroup = editor.survey.surveyItems['test-survey.page1'] as GroupItem; + expect(parentGroup.items).toContain('test-survey.page1.group1-renamed'); + expect(parentGroup.items).not.toContain('test-survey.page1.group1'); + + // Verify group's items array is updated + const renamedGroup = editor.survey.surveyItems['test-survey.page1.group1-renamed'] as GroupItem; + expect(renamedGroup.items).toContain('test-survey.page1.group1-renamed.question1'); + }); + + test('should update parent keys for nested items at multiple levels', () => { + // Create a nested structure: root -> page1 -> group1 -> subgroup -> question + const groupItem = new GroupItem('test-survey.page1.group1'); + const groupTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, groupItem, groupTranslations); + + const subGroupItem = new GroupItem('test-survey.page1.group1.subgroup'); + const subGroupTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1.group1' }, subGroupItem, subGroupTranslations); + + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.group1.subgroup.question1'); + const questionTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1.group1.subgroup' }, questionItem, questionTranslations); + + // Verify initial parent keys + expect(subGroupItem.key.parentFullKey).toBe('test-survey.page1.group1'); + expect(questionItem.key.parentFullKey).toBe('test-survey.page1.group1.subgroup'); + + // Change the top-level group key + editor.onItemKeyChanged('test-survey.page1.group1', 'test-survey.page1.group1-renamed'); + + // Verify all nested items have updated parent keys + const renamedSubGroup = editor.survey.surveyItems['test-survey.page1.group1-renamed.subgroup']; + const renamedQuestion = editor.survey.surveyItems['test-survey.page1.group1-renamed.subgroup.question1']; + + expect(renamedSubGroup).toBeDefined(); + expect(renamedQuestion).toBeDefined(); + + // Check parent keys are correctly updated + expect(renamedSubGroup.key.parentFullKey).toBe('test-survey.page1.group1-renamed'); + expect(renamedQuestion.key.parentFullKey).toBe('test-survey.page1.group1-renamed.subgroup'); + + // Verify old keys are removed + expect(editor.survey.surveyItems['test-survey.page1.group1.subgroup']).toBeUndefined(); + expect(editor.survey.surveyItems['test-survey.page1.group1.subgroup.question1']).toBeUndefined(); + }); + }); + + describe('Template values, display, disabled and validation references', () => { + test('should update templateValues references to the changed item', () => { + // Add a question item + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const questionTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, questionTranslations); + + // Add template values with reference to the item + questionItem.templateValues = { + 'template1': createTemplateValueWithReference('test-survey.page1.question1') + }; + + // Verify initial template value reference + const initialTemplateValue = questionItem.templateValues['template1']; + expect(initialTemplateValue.expression?.responseVariableRefs[0].toString()).toBe('test-survey.page1.question1...get'); + + // Change the item key + editor.onItemKeyChanged('test-survey.page1.question1', 'test-survey.page1.question1-renamed'); + + // Verify template value reference is updated + const updatedTemplateValue = questionItem.templateValues['template1']; + expect(updatedTemplateValue.expression?.responseVariableRefs[0].toString()).toBe('test-survey.page1.question1-renamed...get'); + }); + + test('should update display conditions references to the changed item', () => { + // Add a question item + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const questionTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, questionTranslations); + + // Add display conditions with reference to the item + questionItem.displayConditions = { + root: createExpressionWithReference('test-survey.page1.question1'), + components: { + 'title': createExpressionWithReference('test-survey.page1.question1') + } + }; + + // Verify initial display condition references + expect(questionItem.displayConditions.root?.responseVariableRefs[0].toString()).toBe('test-survey.page1.question1...get'); + expect(questionItem.displayConditions.components?.['title']?.responseVariableRefs[0].toString()).toBe('test-survey.page1.question1...get'); + + // Change the item key + editor.onItemKeyChanged('test-survey.page1.question1', 'test-survey.page1.question1-renamed'); + + // Verify display condition references are updated + expect(questionItem.displayConditions.root?.responseVariableRefs[0].toString()).toBe('test-survey.page1.question1-renamed...get'); + expect(questionItem.displayConditions.components?.['title']?.responseVariableRefs[0].toString()).toBe('test-survey.page1.question1-renamed...get'); + }); + + test('should update disabled conditions references to the changed item', () => { + // Add a question item + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const questionTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, questionTranslations); + + // Add disabled conditions with reference to the item + questionItem.disabledConditions = { + components: { + 'title': createExpressionWithReference('test-survey.page1.question1') + } + }; + + // Verify initial disabled condition references + expect(questionItem.disabledConditions.components?.['title']?.responseVariableRefs[0].toString()).toBe('test-survey.page1.question1...get'); + + // Change the item key + editor.onItemKeyChanged('test-survey.page1.question1', 'test-survey.page1.question1-renamed'); + + // Verify disabled condition references are updated + expect(questionItem.disabledConditions.components?.['title']?.responseVariableRefs[0].toString()).toBe('test-survey.page1.question1-renamed...get'); + }); + + test('should update validation references to the changed item', () => { + // Add a question item + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const questionTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, questionTranslations); + + // Add validations with reference to the item + questionItem.validations = { + 'validation1': createExpressionWithReference('test-survey.page1.question1') + }; + + // Verify initial validation references + expect(questionItem.validations['validation1']?.responseVariableRefs[0].toString()).toBe('test-survey.page1.question1...get'); + + // Change the item key + editor.onItemKeyChanged('test-survey.page1.question1', 'test-survey.page1.question1-renamed'); + + // Verify validation references are updated + expect(questionItem.validations['validation1']?.responseVariableRefs[0].toString()).toBe('test-survey.page1.question1-renamed...get'); + }); + }); + + describe('Parent item list updates', () => { + test('should update parent item list when changing item key', () => { + // Add a display item + const displayItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem, testTranslations); + + // Verify item is in parent's items array + const parentGroup = editor.survey.surveyItems['test-survey.page1'] as GroupItem; + expect(parentGroup.items).toContain('test-survey.page1.display1'); + + // Change the item key + editor.onItemKeyChanged('test-survey.page1.display1', 'test-survey.page1.display1-renamed'); + + // Verify parent's items array is updated + expect(parentGroup.items).toContain('test-survey.page1.display1-renamed'); + expect(parentGroup.items).not.toContain('test-survey.page1.display1'); + }); + + test('should update parent item list when changing nested group key', () => { + // Add a group + const groupItem = new GroupItem('test-survey.page1.group1'); + const groupTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, groupItem, groupTranslations); + + // Add a question inside the group + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.group1.question1'); + const questionTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1.group1' }, questionItem, questionTranslations); + + // Verify initial structure + const parentGroup = editor.survey.surveyItems['test-survey.page1'] as GroupItem; + const group = editor.survey.surveyItems['test-survey.page1.group1'] as GroupItem; + expect(parentGroup.items).toContain('test-survey.page1.group1'); + expect(group.items).toContain('test-survey.page1.group1.question1'); + + // Change the group key + editor.onItemKeyChanged('test-survey.page1.group1', 'test-survey.page1.group1-renamed'); + + // Verify parent's items array is updated + expect(parentGroup.items).toContain('test-survey.page1.group1-renamed'); + expect(parentGroup.items).not.toContain('test-survey.page1.group1'); + + // Verify group's items array is updated + const renamedGroup = editor.survey.surveyItems['test-survey.page1.group1-renamed'] as GroupItem; + expect(renamedGroup.items).toContain('test-survey.page1.group1-renamed.question1'); + }); + }); + + describe('Commit history', () => { + test('should have only one commit in history after changing a nested item', () => { + // Add a group + const groupItem = new GroupItem('test-survey.page1.group1'); + const groupTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, groupItem, groupTranslations); + + // Add a question inside the group + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.group1.question1'); + const questionTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1.group1' }, questionItem, questionTranslations); + + // Get initial commit count + const initialMemoryUsage = editor.getMemoryUsage(); + const initialEntries = initialMemoryUsage.entries; + + // Change the group key (this should trigger recursive changes for nested items) + editor.onItemKeyChanged('test-survey.page1.group1', 'test-survey.page1.group1-renamed'); + + // Verify only one new commit was added + const finalMemoryUsage = editor.getMemoryUsage(); + expect(finalMemoryUsage.entries).toBe(initialEntries + 1); + + // Verify the commit description + expect(editor.getUndoDescription()).toBe('Renamed test-survey.page1.group1 to test-survey.page1.group1-renamed'); + }); + + test('should handle skipCommit parameter correctly', () => { + // Add a display item + const displayItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem, testTranslations); + + // Get initial commit count + const initialMemoryUsage = editor.getMemoryUsage(); + const initialEntries = initialMemoryUsage.entries; + + // Change the item key with skipCommit = true + editor.onItemKeyChanged('test-survey.page1.display1', 'test-survey.page1.display1-renamed', true); + + // Verify no new commit was added + const finalMemoryUsage = editor.getMemoryUsage(); + expect(finalMemoryUsage.entries).toBe(initialEntries); + + // Verify there are uncommitted changes + expect(editor.hasUncommittedChanges).toBe(true); + }); + }); + + describe('Error handling', () => { + test('should not change if item key already exists', () => { + // Add two display items + const displayItem1 = new DisplayItem('test-survey.page1.display1'); + const displayItem2 = new DisplayItem('test-survey.page1.display2'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem2, testTranslations); + + // Verify both items exist + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.display2']).toBeDefined(); + + // Try to rename display1 to display2 (which already exists) + expect(() => { + editor.onItemKeyChanged('test-survey.page1.display1', 'test-survey.page1.display2'); + }).toThrow("Item with key 'test-survey.page1.display2' already exists. Cannot rename test-survey.page1.display1 to test-survey.page1.display2"); + + // Verify items remain unchanged + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.display2']).toBeDefined(); + expect(displayItem1.key.fullKey).toBe('test-survey.page1.display1'); + }); + + test('should throw error when trying to change non-existent item', () => { + expect(() => { + editor.onItemKeyChanged('non-existent-item', 'new-key'); + }).toThrow("Item with key 'non-existent-item' not found"); + }); + }); + + describe('Translations updates', () => { + test('should update translations when item key changes', () => { + // Add a display item with translations + const displayItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem, testTranslations); + + // Verify translations exist for original key + expect(editor.survey.getItemTranslations('test-survey.page1.display1')).toBeDefined(); + + // Change the item key + editor.onItemKeyChanged('test-survey.page1.display1', 'test-survey.page1.display1-renamed'); + + // Verify translations exist for new key + expect(editor.survey.getItemTranslations('test-survey.page1.display1-renamed')).toBeDefined(); + + // Verify translations don't exist for old key + expect(() => { + editor.survey.getItemTranslations('test-survey.page1.display1'); + }).toThrow('Item test-survey.page1.display1 not found'); + }); + }); +}); diff --git a/src/__tests__/survey-editor.test.ts b/src/__tests__/survey-editor.test.ts new file mode 100644 index 0000000..bc4ecf8 --- /dev/null +++ b/src/__tests__/survey-editor.test.ts @@ -0,0 +1,2348 @@ +import { Survey } from '../survey/survey'; +import { SurveyEditor } from '../survey-editor/survey-editor'; +import { DisplayItem, GroupItem, SingleChoiceQuestionItem, SurveyItemType } from '../survey/items'; +import { SurveyItemTranslations } from '../survey/utils'; +import { Content, ContentType } from '../survey/utils/content'; +import { DisplayComponent, ItemComponentType, TextComponent } from '../survey/components'; +import { Expression, ConstExpression, ResponseVariableExpression, FunctionExpression, FunctionExpressionNames } from '../expressions'; +import { SingleChoiceQuestionEditor } from '../survey-editor/survey-item-editors'; +import { SurveyEditorUndoRedo } from '../survey-editor/undo-redo'; + +// Helper function to create a test survey +const createTestSurvey = (surveyKey: string = 'test-survey'): Survey => { + const survey = new Survey(surveyKey); + + // Add a sub-group to the root + const subGroup = new GroupItem(`${surveyKey}.page1`); + survey.surveyItems[`${surveyKey}.page1`] = subGroup; + + // Add the sub-group to the root group's items + const rootGroup = survey.surveyItems[surveyKey] as GroupItem; + rootGroup.items = [`${surveyKey}.page1`]; + + return survey; +}; + +const enLocale = 'en'; +const deLocale = 'de'; + +// Helper function to create test translations +const createTestTranslations = (): SurveyItemTranslations => { + const translations = new SurveyItemTranslations(); + const testContent: Content = { + type: ContentType.md, + content: 'Test content' + }; + translations.setContent(enLocale, 'title', testContent); + translations.setContent(deLocale, 'title', { type: ContentType.md, content: 'Test Inhalt' }); + return translations; +}; + +// Helper function to create test expressions +const createTestExpression = (): Expression => { + return new ConstExpression(true); +}; + +const createComplexTestExpression = (): Expression => { + return new FunctionExpression( + FunctionExpressionNames.eq, + [ + new ResponseVariableExpression('test-survey.page1.question1...get'), + new ConstExpression('option1') + ] + ); +}; + +describe('SurveyEditor', () => { + let survey: Survey; + let editor: SurveyEditor; + + beforeEach(() => { + survey = createTestSurvey(); + editor = new SurveyEditor(survey); + }); + + describe('Constructor and Basic Properties', () => { + test('should initialize with a survey', () => { + expect(editor.survey).toBe(survey); + expect(editor.hasUncommittedChanges).toBe(false); + }); + + test('should provide access to survey items', () => { + expect(editor.survey.surveyItems).toBeDefined(); + expect(Object.keys(editor.survey.surveyItems)).toContain('test-survey'); + expect(Object.keys(editor.survey.surveyItems)).toContain('test-survey.page1'); + }); + + test('should initialize undo/redo functionality', () => { + expect(editor.canUndo()).toBe(false); + expect(editor.canRedo()).toBe(false); + }); + }); + + describe('Commit and Uncommitted Changes', () => { + test('should track uncommitted changes after modifications', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // addItem automatically commits, so hasUncommittedChanges should be false + expect(editor.hasUncommittedChanges).toBe(false); + }); + + test('should commit changes with description', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + expect(editor.canUndo()).toBe(true); + expect(editor.getUndoDescription()).toBe('Added test-survey.page1.item1'); + }); + + test('should track uncommitted changes when updating item translations', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Now update translations (this should mark as modified without committing) + const updatedTranslations = createTestTranslations(); + updatedTranslations.setContent('es', 'title', { type: ContentType.md, content: 'Contenido de prueba' }); + + editor.updateItemTranslations('test-survey.page1.item1', updatedTranslations); + + expect(editor.hasUncommittedChanges).toBe(true); + }); + + test('should commit if needed when starting new operations', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Update translations to create uncommitted changes + const updatedTranslations = createTestTranslations(); + editor.updateItemTranslations('test-survey.page1.item1', updatedTranslations); + + expect(editor.hasUncommittedChanges).toBe(true); + + // Adding a new item should commit the previous changes + const testItem2 = new DisplayItem('test-survey.page1.item2'); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + + expect(editor.hasUncommittedChanges).toBe(false); + }); + }); + + describe('Undo/Redo Functionality', () => { + test('should support undo after adding items', () => { + const initialItemCount = Object.keys(editor.survey.surveyItems).length; + + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + expect(Object.keys(editor.survey.surveyItems)).toHaveLength(initialItemCount + 1); + expect(editor.canUndo()).toBe(true); + + const undoSuccess = editor.undo(); + expect(undoSuccess).toBe(true); + expect(Object.keys(editor.survey.surveyItems)).toHaveLength(initialItemCount); + expect(editor.survey.surveyItems['test-survey.page1.item1']).toBeUndefined(); + }); + + test('should support redo after undo', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + editor.undo(); + expect(editor.canRedo()).toBe(true); + + const redoSuccess = editor.redo(); + expect(redoSuccess).toBe(true); + expect(editor.survey.surveyItems['test-survey.page1.item1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.item1'].itemType).toBe(SurveyItemType.Display); + }); + + test('should handle undo with uncommitted changes', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Make an uncommitted change + const updatedTranslations = createTestTranslations(); + editor.updateItemTranslations('test-survey.page1.item1', updatedTranslations); + + expect(editor.hasUncommittedChanges).toBe(true); + + // Undo should revert to last committed state + const undoSuccess = editor.undo(); + expect(undoSuccess).toBe(true); + expect(editor.hasUncommittedChanges).toBe(false); + }); + + test('should not allow redo with uncommitted changes', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + editor.undo(); + + // Redo first to restore the item + editor.redo(); + + // Make an uncommitted change + const updatedTranslations = createTestTranslations(); + editor.updateItemTranslations('test-survey.page1.item1', updatedTranslations); + + expect(editor.hasUncommittedChanges).toBe(true); + expect(editor.canRedo()).toBe(false); + + const redoSuccess = editor.redo(); + expect(redoSuccess).toBe(false); + }); + + test('should provide undo/redo descriptions', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + expect(editor.getUndoDescription()).toBe('Added test-survey.page1.item1'); + expect(editor.getRedoDescription()).toBeNull(); + + editor.undo(); + expect(editor.getUndoDescription()).toBeNull(); + expect(editor.getRedoDescription()).toBe('Added test-survey.page1.item1'); + }); + }); + + describe('Adding Items', () => { + test('should add item to specified parent group', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBe(testItem); + + const parentGroup = editor.survey.surveyItems['test-survey.page1'] as GroupItem; + expect(parentGroup.items).toContain('test-survey.page1.display1'); + }); + + test('should add item at specified index', () => { + const testItem1 = new DisplayItem('test-survey.page1.display1'); + const testItem2 = new DisplayItem('test-survey.page1.display2'); + const testItem3 = new DisplayItem('test-survey.page1.display3'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem3, testTranslations); + + // Insert at index 1 (between item1 and item3) + editor.addItem({ parentKey: 'test-survey.page1', index: 1 }, testItem2, testTranslations); + + const parentGroup = editor.survey.surveyItems['test-survey.page1'] as GroupItem; + expect(parentGroup.items).toEqual([ + 'test-survey.page1.display1', + 'test-survey.page1.display2', + 'test-survey.page1.display3' + ]); + }); + + test('should add item to root when no target specified', () => { + // Create a survey with only root group + const rootOnlySurvey = new Survey('root-survey'); + const rootEditor = new SurveyEditor(rootOnlySurvey); + + const testItem = new DisplayItem('root-survey.display1'); + const testTranslations = createTestTranslations(); + + rootEditor.addItem(undefined, testItem, testTranslations); + + expect(rootEditor.survey.surveyItems['root-survey.display1']).toBeDefined(); + + const rootGroup = rootEditor.survey.surveyItems['root-survey'] as GroupItem; + expect(rootGroup.items).toContain('root-survey.display1'); + }); + + test('should set item translations when adding', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const retrievedTranslations = editor.survey.getItemTranslations('test-survey.page1.display1'); + expect(retrievedTranslations).toBeDefined(); + + // Check if translations were actually set + const localeContent = retrievedTranslations!.getAllForLocale(enLocale); + expect(localeContent).toBeDefined(); + expect(localeContent!['title']).toEqual({ + type: ContentType.md, + content: 'Test content' + }); + }); + + test('should handle adding different types of survey items', () => { + const displayItem = new DisplayItem('test-survey.page1.display1'); + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const groupItem = new GroupItem('test-survey.page1.group1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, questionItem, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, groupItem, testTranslations); + + expect(editor.survey.surveyItems['test-survey.page1.display1'].itemType).toBe(SurveyItemType.Display); + expect(editor.survey.surveyItems['test-survey.page1.question1'].itemType).toBe(SurveyItemType.SingleChoiceQuestion); + expect(editor.survey.surveyItems['test-survey.page1.group1'].itemType).toBe(SurveyItemType.Group); + }); + + test('should throw error when parent group not found', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + + expect(() => { + editor.addItem({ parentKey: 'non-existent-parent' }, testItem, testTranslations); + }).toThrow("Parent item with key 'non-existent-parent' not found"); + }); + + test('should throw error when parent is not a group item', () => { + // First add a display item + const displayItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, displayItem, testTranslations); + + // Try to add an item to the display item (which is not a group) + const testItem = new DisplayItem('test-survey.page1.display1.invalid'); + + expect(() => { + editor.addItem({ parentKey: 'test-survey.page1.display1' }, testItem, testTranslations); + }).toThrow("Parent item 'test-survey.page1.display1' is not a group item"); + }); + + test('should throw error when no root group found', () => { + // Create a survey with no root group (edge case) + const emptySurvey = new Survey(); + emptySurvey.surveyItems = {}; // Remove the default root group + const emptyEditor = new SurveyEditor(emptySurvey); + + const testItem = new DisplayItem('display1'); + const testTranslations = createTestTranslations(); + + expect(() => { + emptyEditor.addItem(undefined, testItem, testTranslations); + }).toThrow('No root group found in survey'); + }); + }); + + describe('Removing Items', () => { + test('should remove item and update parent group', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeDefined(); + + const removeSuccess = editor.removeItem('test-survey.page1.display1'); + + expect(removeSuccess).toBe(true); + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeUndefined(); + + const parentGroup = editor.survey.surveyItems['test-survey.page1'] as GroupItem; + expect(parentGroup.items).not.toContain('test-survey.page1.display1'); + }); + + test('should remove item translations when removing item', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Verify translations exist + expect(editor.survey.getItemTranslations('test-survey.page1.display1')).toBeDefined(); + + editor.removeItem('test-survey.page1.display1'); + + // After removal, getting translations for non-existent item should throw error + expect(() => { + editor.survey.getItemTranslations('test-survey.page1.display1'); + }).toThrow('Item test-survey.page1.display1 not found'); + }); + + test('should return false when trying to remove non-existent item', () => { + const removeSuccess = editor.removeItem('non-existent-item'); + expect(removeSuccess).toBe(false); + }); + + test('should throw error when trying to remove root item', () => { + expect(() => { + editor.removeItem('test-survey'); + }).toThrow("Item with key 'test-survey' is the root item"); + }); + + test('should handle removing items from different positions', () => { + const testItem1 = new DisplayItem('test-survey.page1.display1'); + const testItem2 = new DisplayItem('test-survey.page1.display2'); + const testItem3 = new DisplayItem('test-survey.page1.display3'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem3, testTranslations); + + // Remove middle item + editor.removeItem('test-survey.page1.display2'); + + const parentGroup = editor.survey.surveyItems['test-survey.page1'] as GroupItem; + expect(parentGroup.items).toEqual([ + 'test-survey.page1.display1', + 'test-survey.page1.display3' + ]); + }); + + test('should remove group with all nested items and translations as single operation', () => { + const testGroup = new GroupItem('test-survey.page1.subgroup1'); + const testItem1 = new DisplayItem('test-survey.page1.subgroup1.display1'); + const testItem2 = new DisplayItem('test-survey.page1.subgroup1.display2'); + const nestedGroup = new GroupItem('test-survey.page1.subgroup1.nestedgroup'); + const nestedItem = new DisplayItem('test-survey.page1.subgroup1.nestedgroup.display1'); + const testTranslations = createTestTranslations(); + + // Build nested structure + editor.addItem({ parentKey: 'test-survey.page1' }, testGroup, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1.subgroup1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1.subgroup1' }, testItem2, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1.subgroup1' }, nestedGroup, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1.subgroup1.nestedgroup' }, nestedItem, testTranslations); + + // Verify all items exist + expect(editor.survey.surveyItems['test-survey.page1.subgroup1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.subgroup1.display1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.subgroup1.display2']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.subgroup1.nestedgroup']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.subgroup1.nestedgroup.display1']).toBeDefined(); + + // Verify translations exist for all items + expect(editor.survey.getItemTranslations('test-survey.page1.subgroup1')).toBeDefined(); + expect(editor.survey.getItemTranslations('test-survey.page1.subgroup1.display1')).toBeDefined(); + expect(editor.survey.getItemTranslations('test-survey.page1.subgroup1.display2')).toBeDefined(); + expect(editor.survey.getItemTranslations('test-survey.page1.subgroup1.nestedgroup')).toBeDefined(); + expect(editor.survey.getItemTranslations('test-survey.page1.subgroup1.nestedgroup.display1')).toBeDefined(); + + const initialUndoCount = editor.canUndo() ? 1 : 0; // Check how many operations we can undo + let undoCount = 0; + while (editor.canUndo()) { + undoCount++; + editor.undo(); + } + // Redo all to get back to initial state + for (let i = 0; i < undoCount; i++) { + editor.redo(); + } + + // Remove the group with all nested items + const removeSuccess = editor.removeItem('test-survey.page1.subgroup1'); + + expect(removeSuccess).toBe(true); + + // Verify all nested items and the group are removed from survey items + expect(editor.survey.surveyItems['test-survey.page1.subgroup1']).toBeUndefined(); + expect(editor.survey.surveyItems['test-survey.page1.subgroup1.display1']).toBeUndefined(); + expect(editor.survey.surveyItems['test-survey.page1.subgroup1.display2']).toBeUndefined(); + expect(editor.survey.surveyItems['test-survey.page1.subgroup1.nestedgroup']).toBeUndefined(); + expect(editor.survey.surveyItems['test-survey.page1.subgroup1.nestedgroup.display1']).toBeUndefined(); + + // Verify the group is removed from parent's items array + const parentGroup = editor.survey.surveyItems['test-survey.page1'] as GroupItem; + expect(parentGroup.items).not.toContain('test-survey.page1.subgroup1'); + + // Verify all translations are removed + expect(() => editor.survey.getItemTranslations('test-survey.page1.subgroup1')).toThrow(); + expect(() => editor.survey.getItemTranslations('test-survey.page1.subgroup1.display1')).toThrow(); + expect(() => editor.survey.getItemTranslations('test-survey.page1.subgroup1.display2')).toThrow(); + expect(() => editor.survey.getItemTranslations('test-survey.page1.subgroup1.nestedgroup')).toThrow(); + expect(() => editor.survey.getItemTranslations('test-survey.page1.subgroup1.nestedgroup.display1')).toThrow(); + + // Verify this created only ONE undo operation + expect(editor.canUndo()).toBe(true); + expect(editor.getUndoDescription()).toBe('Removed test-survey.page1.subgroup1'); + + // Undo should restore all items + editor.undo(); + + expect(editor.survey.surveyItems['test-survey.page1.subgroup1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.subgroup1.display1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.subgroup1.display2']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.subgroup1.nestedgroup']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.subgroup1.nestedgroup.display1']).toBeDefined(); + }); + }); + + describe('Moving Items', () => { + test('should throw error when moving non-existent item', () => { + expect(() => { + editor.moveItem('non-existent-item', { + parentKey: 'test-survey.page1' + }); + }).toThrow("Item with key 'non-existent-item' not found"); + }); + + test('should throw error when target parent does not exist', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + expect(() => { + editor.moveItem('test-survey.page1.display1', { + parentKey: 'non-existent-parent' + }); + }).toThrow("Target parent with key 'non-existent-parent' not found"); + }); + + test('should throw error when target parent is not a group', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testItem2 = new DisplayItem('test-survey.page1.display2'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + + expect(() => { + editor.moveItem('test-survey.page1.display1', { + parentKey: 'test-survey.page1.display2' // display item, not group + }); + }).toThrow("Target parent 'test-survey.page1.display2' is not a group item"); + }); + + test('should throw error when trying to move item to its descendant', () => { + const groupItem = new GroupItem('test-survey.page1.group1'); + const subGroupItem = new GroupItem('test-survey.page1.group1.subgroup'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, groupItem, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1.group1' }, subGroupItem, testTranslations); + + expect(() => { + editor.moveItem('test-survey.page1.group1', { + parentKey: 'test-survey.page1.group1.subgroup' + }); + }).toThrow("Cannot move item 'test-survey.page1.group1' to its descendant 'test-survey.page1.group1.subgroup'"); + }); + + test('should prevent moving item to itself', () => { + const groupItem = new GroupItem('test-survey.page1.group1'); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, groupItem, testTranslations); + + expect(() => { + editor.moveItem('test-survey.page1.group1', { + parentKey: 'test-survey.page1.group1' + }); + }).toThrow("Cannot move item 'test-survey.page1.group1' to its descendant 'test-survey.page1.group1'"); + }); + + test('should move item between different parents and update keys', () => { + const group1 = new GroupItem('test-survey.page1.group1'); + const group2 = new GroupItem('test-survey.page1.group2'); + const displayItem = new DisplayItem('test-survey.page1.group1.display1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, group1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, group2, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1.group1' }, displayItem, testTranslations); + + // Verify initial state + expect(editor.survey.surveyItems['test-survey.page1.group1.display1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.group2.display1']).toBeUndefined(); + expect((group1 as GroupItem).items).toContain('test-survey.page1.group1.display1'); + expect((group2 as GroupItem).items || []).not.toContain('test-survey.page1.group2.display1'); + + const moveSuccess = editor.moveItem('test-survey.page1.group1.display1', { + parentKey: 'test-survey.page1.group2', + index: 0 + }); + + expect(moveSuccess).toBe(true); + + // Verify item was moved and key updated + expect(editor.survey.surveyItems['test-survey.page1.group1.display1']).toBeUndefined(); + expect(editor.survey.surveyItems['test-survey.page1.group2.display1']).toBeDefined(); + + const movedItem = editor.survey.surveyItems['test-survey.page1.group2.display1']; + expect(movedItem.key.fullKey).toBe('test-survey.page1.group2.display1'); + expect(movedItem.key.parentFullKey).toBe('test-survey.page1.group2'); + + // Verify parent items arrays are updated + expect((group1 as GroupItem).items).not.toContain('test-survey.page1.group1.display1'); + expect((group2 as GroupItem).items).toContain('test-survey.page1.group2.display1'); + }); + + test('should move item with nested children and update all keys correctly', () => { + const group1 = new GroupItem('test-survey.page1.group1'); + const group2 = new GroupItem('test-survey.page1.group2'); + const subGroup = new GroupItem('test-survey.page1.group1.subgroup'); + const nestedItem = new DisplayItem('test-survey.page1.group1.subgroup.display1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, group1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, group2, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1.group1' }, subGroup, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1.group1.subgroup' }, nestedItem, testTranslations); + + const moveSuccess = editor.moveItem('test-survey.page1.group1.subgroup', { + parentKey: 'test-survey.page1.group2' + }); + + expect(moveSuccess).toBe(true); + + // Verify subgroup was moved + expect(editor.survey.surveyItems['test-survey.page1.group1.subgroup']).toBeUndefined(); + expect(editor.survey.surveyItems['test-survey.page1.group2.subgroup']).toBeDefined(); + + // Verify nested item key was updated + expect(editor.survey.surveyItems['test-survey.page1.group1.subgroup.display1']).toBeUndefined(); + expect(editor.survey.surveyItems['test-survey.page1.group2.subgroup.display1']).toBeDefined(); + + const movedNestedItem = editor.survey.surveyItems['test-survey.page1.group2.subgroup.display1']; + expect(movedNestedItem.key.fullKey).toBe('test-survey.page1.group2.subgroup.display1'); + expect(movedNestedItem.key.parentFullKey).toBe('test-survey.page1.group2.subgroup'); + }); + + test('should throw error when moving item to same parent', () => { + const display1 = new DisplayItem('test-survey.page1.display1'); + const display2 = new DisplayItem('test-survey.page1.display2'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, display1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, display2, testTranslations); + + expect(() => { + editor.moveItem('test-survey.page1.display1', { + parentKey: 'test-survey.page1', + index: 1 + }); + }).toThrow("Item 'test-survey.page1.display1' is already in the target parent 'test-survey.page1'"); + }); + + test('should move item to end when no index specified', () => { + const display1 = new DisplayItem('test-survey.page1.display1'); + const display2 = new DisplayItem('test-survey.page1.display2'); + const group1 = new GroupItem('test-survey.page1.group1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, display1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, display2, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, group1, testTranslations); + + const moveSuccess = editor.moveItem('test-survey.page1.display1', { + parentKey: 'test-survey.page1.group1' + }); + + expect(moveSuccess).toBe(true); + + const group1Items = (group1 as GroupItem).items; + expect(group1Items).toBeDefined(); + expect(group1Items![group1Items!.length - 1]).toBe('test-survey.page1.group1.display1'); + }); + + test('should commit changes when moving item', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const group1 = new GroupItem('test-survey.page1.group1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, group1, testTranslations); + + // Make an uncommitted change + const updatedTranslations = createTestTranslations(); + editor.updateItemTranslations('test-survey.page1.display1', updatedTranslations); + + expect(editor.hasUncommittedChanges).toBe(true); + + editor.moveItem('test-survey.page1.display1', { + parentKey: 'test-survey.page1.group1', + index: 0 + }); + + // Should have committed the changes + expect(editor.hasUncommittedChanges).toBe(false); + expect(editor.getUndoDescription()).toBe('Moved test-survey.page1.display1 to test-survey.page1.group1'); + }); + + test('should handle moving to parent with no existing items', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const emptyGroup = new GroupItem('test-survey.page1.emptygroup'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, emptyGroup, testTranslations); + + // Ensure the empty group has no items + expect((emptyGroup as GroupItem).items).toBeUndefined(); + + const moveSuccess = editor.moveItem('test-survey.page1.display1', { + parentKey: 'test-survey.page1.emptygroup' + }); + + expect(moveSuccess).toBe(true); + expect((emptyGroup as GroupItem).items).toEqual(['test-survey.page1.emptygroup.display1']); + }); + }); + + describe('Updating Item Translations', () => { + test('should update item translations and mark as modified', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const updatedTranslations = new SurveyItemTranslations(); + updatedTranslations.setContent(enLocale, 'title', { type: ContentType.md, content: 'Updated content' }); + updatedTranslations.setContent('fr', 'title', { type: ContentType.md, content: 'Contenu mis à jour' }); + + const updateSuccess = editor.updateItemTranslations('test-survey.page1.display1', updatedTranslations); + + expect(updateSuccess).toBe(true); + expect(editor.hasUncommittedChanges).toBe(true); + + const retrievedTranslations = editor.survey.getItemTranslations('test-survey.page1.display1'); + + // Check if translations were updated correctly + const enContent = retrievedTranslations!.getAllForLocale(enLocale); + const frContent = retrievedTranslations!.getAllForLocale('fr'); + + expect(enContent).toBeDefined(); + expect(enContent!['title']).toEqual({ + type: ContentType.md, + content: 'Updated content' + }); + + expect(frContent).toBeDefined(); + expect(frContent!['title']).toEqual({ + type: ContentType.md, + content: 'Contenu mis à jour' + }); + }); + + test('should throw error when updating translations for non-existent item', () => { + const updatedTranslations = createTestTranslations(); + + expect(() => { + editor.updateItemTranslations('non-existent-item', updatedTranslations); + }).toThrow("Item with key 'non-existent-item' not found"); + }); + + test('should handle updating with undefined translations', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const updateSuccess = editor.updateItemTranslations('test-survey.page1.display1', undefined); + + expect(updateSuccess).toBe(true); + expect(editor.hasUncommittedChanges).toBe(true); + }); + }); + + describe('Deleting Components', () => { + test('should delete component and update item', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + testItem.components = [ + new DisplayComponent(ItemComponentType.Text, 'title', undefined, 'test-survey.page1.display1'), + new DisplayComponent(ItemComponentType.Text, 'description', undefined, 'test-survey.page1.display1') + ]; + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + expect(testItem.components).toHaveLength(2); + + editor.deleteComponent('test-survey.page1.display1', 'title'); + + expect(testItem.components).toHaveLength(1); + expect(testItem.components![0].key.componentKey).toBe('description'); + }); + + test('should throw error when deleting component from non-existent item', () => { + expect(() => { + editor.deleteComponent('non-existent-item', 'component'); + }).toThrow("Item with key 'non-existent-item' not found"); + }); + + test('should commit changes and remove translations when deleting component', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + testItem.components = [ + new TextComponent('title', undefined, 'test-survey.page1.display1') + ]; + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Make uncommitted changes first + const updatedTranslations = createTestTranslations(); + editor.updateItemTranslations('test-survey.page1.display1', updatedTranslations); + + expect(editor.hasUncommittedChanges).toBe(true); + + editor.deleteComponent('test-survey.page1.display1', 'title'); + + // Should have committed the previous changes and this operation + expect(editor.hasUncommittedChanges).toBe(false); + expect(editor.canUndo()).toBe(true); + expect(editor.getUndoDescription()).toBe('Deleted component title from test-survey.page1.display1'); + }); + }); + + describe('Memory Usage and Configuration', () => { + test('should provide memory usage statistics', () => { + const memoryUsage = editor.getMemoryUsage(); + + expect(memoryUsage).toHaveProperty('totalMB'); + expect(memoryUsage).toHaveProperty('entries'); + expect(typeof memoryUsage.totalMB).toBe('number'); + expect(typeof memoryUsage.entries).toBe('number'); + expect(memoryUsage.entries).toBeGreaterThan(0); + }); + + test('should provide undo/redo configuration', () => { + const config = editor.getUndoRedoConfig(); + + expect(config).toHaveProperty('maxTotalMemoryMB'); + expect(config).toHaveProperty('minHistorySize'); + expect(config).toHaveProperty('maxHistorySize'); + expect(typeof config.maxTotalMemoryMB).toBe('number'); + expect(typeof config.minHistorySize).toBe('number'); + expect(typeof config.maxHistorySize).toBe('number'); + }); + + test('should track memory usage increase with operations', () => { + const initialUsage = editor.getMemoryUsage(); + + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const newUsage = editor.getMemoryUsage(); + expect(newUsage.entries).toBeGreaterThan(initialUsage.entries); + expect(newUsage.totalMB).toBeGreaterThanOrEqual(initialUsage.totalMB); + }); + }); + + describe('Complex Operations and Integration', () => { + test('should handle multiple operations with undo/redo', () => { + const testItem1 = new DisplayItem('test-survey.page1.display1'); + const testItem2 = new DisplayItem('test-survey.page1.display2'); + const testTranslations = createTestTranslations(); + + // Add first item + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + expect(editor.getUndoDescription()).toBe('Added test-survey.page1.display1'); + + // Add second item + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + expect(editor.getUndoDescription()).toBe('Added test-survey.page1.display2'); + + // Undo last operation + editor.undo(); + expect(editor.survey.surveyItems['test-survey.page1.display2']).toBeUndefined(); + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeDefined(); + + // Undo first operation + editor.undo(); + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeUndefined(); + + // Redo both operations + editor.redo(); + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeDefined(); + + editor.redo(); + expect(editor.survey.surveyItems['test-survey.page1.display2']).toBeDefined(); + }); + + test('should handle mixed operations (add, remove, update)', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + + // Add item + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Update translations + const updatedTranslations = createTestTranslations(); + updatedTranslations.setContent('es', 'title', { type: ContentType.md, content: 'Contenido' }); + editor.updateItemTranslations('test-survey.page1.display1', updatedTranslations); + + // Remove item (this should commit the translation update first) + const removeSuccess = editor.removeItem('test-survey.page1.display1'); + + expect(removeSuccess).toBe(true); + expect(editor.hasUncommittedChanges).toBe(false); + expect(editor.canUndo()).toBe(true); + expect(editor.getUndoDescription()).toBe('Removed test-survey.page1.display1'); + }); + + test('should maintain survey integrity across operations', () => { + const testItem1 = new DisplayItem('test-survey.page1.display1'); + const testItem2 = new GroupItem('test-survey.page1.subgroup1'); + const testItem3 = new DisplayItem('test-survey.page1.subgroup1.display1'); + const testTranslations = createTestTranslations(); + + // Build a nested structure + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1.subgroup1' }, testItem3, testTranslations); + + // Verify structure + const page1Group = editor.survey.surveyItems['test-survey.page1'] as GroupItem; + expect(page1Group.items).toContain('test-survey.page1.display1'); + expect(page1Group.items).toContain('test-survey.page1.subgroup1'); + + const subGroup = editor.survey.surveyItems['test-survey.page1.subgroup1'] as GroupItem; + expect(subGroup.items).toContain('test-survey.page1.subgroup1.display1'); + + // Undo operations and verify cleanup + editor.undo(); // Remove nested display item + expect(editor.survey.surveyItems['test-survey.page1.subgroup1.display1']).toBeUndefined(); + + editor.undo(); // Remove subgroup + expect(editor.survey.surveyItems['test-survey.page1.subgroup1']).toBeUndefined(); + + const page1GroupAfterUndo = editor.survey.surveyItems['test-survey.page1'] as GroupItem; + expect(page1GroupAfterUndo.items).not.toContain('test-survey.page1.subgroup1'); + expect(page1GroupAfterUndo.items).toContain('test-survey.page1.display1'); + }); + + test('should handle edge case with empty parent group items array', () => { + // Create a group with no items array + const emptyGroup = new GroupItem('test-survey.empty-group'); + editor.survey.surveyItems['test-survey.empty-group'] = emptyGroup; + + const rootGroup = editor.survey.surveyItems['test-survey'] as GroupItem; + if (!rootGroup.items) rootGroup.items = []; + rootGroup.items.push('test-survey.empty-group'); + + const testItem = new DisplayItem('test-survey.empty-group.display1'); + const testTranslations = createTestTranslations(); + + // Should initialize items array and add item + editor.addItem({ parentKey: 'test-survey.empty-group' }, testItem, testTranslations); + + expect(emptyGroup.items).toBeDefined(); + expect(emptyGroup.items).toContain('test-survey.empty-group.display1'); + }); + }); + + describe('Display Conditions', () => { + test('should set and get root display condition', () => { + const testItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const itemEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + const testCondition = createTestExpression(); + + // Set root display condition + itemEditor.setDisplayCondition(testCondition); + + // Get root display condition + const retrievedCondition = itemEditor.getDisplayCondition(); + + expect(retrievedCondition).toBeDefined(); + expect(retrievedCondition).toBeInstanceOf(ConstExpression); + expect((retrievedCondition as ConstExpression).value).toBe(true); + expect(retrievedCondition).not.toBe(testCondition); // Should be a clone + }); + + test('should set and get component display condition', () => { + const testItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const itemEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + const testCondition = createComplexTestExpression(); + const componentKey = 'test-component'; + + // Set component display condition + itemEditor.setDisplayCondition(testCondition, componentKey); + + // Get component display condition + const retrievedCondition = itemEditor.getDisplayCondition(componentKey); + + expect(retrievedCondition).toBeDefined(); + expect(retrievedCondition).toBeInstanceOf(FunctionExpression); + expect((retrievedCondition as FunctionExpression).functionName).toBe(FunctionExpressionNames.eq); + expect(retrievedCondition).not.toBe(testCondition); // Should be a clone + }); + + test('should remove root display condition by passing undefined', () => { + const testItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const itemEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + const testCondition = createTestExpression(); + + // Set root display condition + itemEditor.setDisplayCondition(testCondition); + expect(itemEditor.getDisplayCondition()).toBeDefined(); + + // Remove root display condition + itemEditor.setDisplayCondition(undefined); + expect(itemEditor.getDisplayCondition()).toBeUndefined(); + }); + + test('should remove component display condition by passing undefined', () => { + const testItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const itemEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + const testCondition = createTestExpression(); + const componentKey = 'test-component'; + + // Set component display condition + itemEditor.setDisplayCondition(testCondition, componentKey); + expect(itemEditor.getDisplayCondition(componentKey)).toBeDefined(); + + // Remove component display condition + itemEditor.setDisplayCondition(undefined, componentKey); + expect(itemEditor.getDisplayCondition(componentKey)).toBeUndefined(); + }); + + test('should return undefined for non-existent display conditions', () => { + const testItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const itemEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + + // Get non-existent root display condition + expect(itemEditor.getDisplayCondition()).toBeUndefined(); + + // Get non-existent component display condition + expect(itemEditor.getDisplayCondition('non-existent-component')).toBeUndefined(); + }); + + test('should commit changes when setting display conditions', () => { + const testItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const itemEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + const testCondition = createTestExpression(); + + // Make an uncommitted change first + const updatedTranslations = createTestTranslations(); + editor.updateItemTranslations('test-survey.page1.question1', updatedTranslations); + + expect(editor.hasUncommittedChanges).toBe(true); + + // Set display condition should commit changes + itemEditor.setDisplayCondition(testCondition); + + expect(editor.hasUncommittedChanges).toBe(false); + expect(editor.canUndo()).toBe(true); + expect(editor.getUndoDescription()).toBe('Set display condition for test-survey.page1.question1'); + }); + + test('should handle multiple display conditions on same item', () => { + const testItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const itemEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + const rootCondition = createTestExpression(); + const componentCondition = createComplexTestExpression(); + + // Set both root and component display conditions + itemEditor.setDisplayCondition(rootCondition); + itemEditor.setDisplayCondition(componentCondition, 'test-component'); + + // Verify both conditions exist + const retrievedRootCondition = itemEditor.getDisplayCondition(); + const retrievedComponentCondition = itemEditor.getDisplayCondition('test-component'); + + expect(retrievedRootCondition).toBeDefined(); + expect(retrievedComponentCondition).toBeDefined(); + expect(retrievedRootCondition).toBeInstanceOf(ConstExpression); + expect(retrievedComponentCondition).toBeInstanceOf(FunctionExpression); + }); + + test('should handle display conditions with complex expression structures', () => { + const testItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const itemEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + + // Create nested function expression + const nestedCondition = new FunctionExpression( + FunctionExpressionNames.and, + [ + new FunctionExpression( + FunctionExpressionNames.eq, + [new ResponseVariableExpression('test-survey.page1.question1...get'), new ConstExpression('option1')] + ), + new FunctionExpression( + FunctionExpressionNames.gt, + [new ConstExpression(2), new ConstExpression(5)] + ) + ] + ); + + // Set complex display condition + itemEditor.setDisplayCondition(nestedCondition); + + // Retrieve and verify + const retrievedCondition = itemEditor.getDisplayCondition(); + expect(retrievedCondition).toBeDefined(); + expect(retrievedCondition).toBeInstanceOf(FunctionExpression); + expect((retrievedCondition as FunctionExpression).functionName).toBe(FunctionExpressionNames.and); + expect((retrievedCondition as FunctionExpression).arguments).toHaveLength(2); + }); + + test('should support undo/redo for display condition changes', () => { + const testItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const itemEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + const testCondition = createTestExpression(); + + // Set display condition + itemEditor.setDisplayCondition(testCondition); + expect(itemEditor.getDisplayCondition()).toBeDefined(); + + // Undo - need to create new editor to see the undone state + editor.undo(); + const itemEditorAfterUndo = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + expect(itemEditorAfterUndo.getDisplayCondition()).toBeUndefined(); + + // Redo - need to create new editor to see the redone state + editor.redo(); + const itemEditorAfterRedo = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + expect(itemEditorAfterRedo.getDisplayCondition()).toBeDefined(); + expect(itemEditorAfterRedo.getDisplayCondition()).toBeInstanceOf(ConstExpression); + }); + + test('should throw error when trying to set display condition on non-existent item', () => { + expect(() => { + new SingleChoiceQuestionEditor(editor, 'non-existent-item'); + }).toThrow('Item non-existent-item not found in survey'); + }); + }); + + describe('getSiblingKeys', () => { + test('should return empty array when item has no siblings', () => { + const testItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const itemEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + const siblingKeys = itemEditor.getSiblingKeys(); + + expect(siblingKeys).toEqual([]); + }); + + test('should return sibling keys when item has siblings', () => { + const testItem1 = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testItem2 = new SingleChoiceQuestionItem('test-survey.page1.question2'); + const testItem3 = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem3, testTranslations); + + const itemEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + const siblingKeys = itemEditor.getSiblingKeys(); + + expect(siblingKeys).toHaveLength(2); + expect(siblingKeys.map(key => key.fullKey)).toContain('test-survey.page1.question2'); + expect(siblingKeys.map(key => key.fullKey)).toContain('test-survey.page1.display1'); + expect(siblingKeys.map(key => key.fullKey)).not.toContain('test-survey.page1.question1'); + }); + + test('should not include items from different parent groups as siblings', () => { + // Add items to page1 + const testItem1 = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testItem2 = new SingleChoiceQuestionItem('test-survey.page1.question2'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + + // Add a new group and items to it + const subGroup2 = new GroupItem('test-survey.page2'); + editor.addItem({ parentKey: 'test-survey' }, subGroup2, testTranslations); + + const testItem3 = new SingleChoiceQuestionItem('test-survey.page2.question1'); + editor.addItem({ parentKey: 'test-survey.page2' }, testItem3, testTranslations); + + const itemEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + const siblingKeys = itemEditor.getSiblingKeys(); + + expect(siblingKeys).toHaveLength(1); + expect(siblingKeys.map(key => key.fullKey)).toContain('test-survey.page1.question2'); + expect(siblingKeys.map(key => key.fullKey)).not.toContain('test-survey.page2.question1'); + }); + + test('should work correctly with nested survey structure', () => { + // Create a nested structure: survey > page1 > subgroup > questions + const subGroup = new GroupItem('test-survey.page1.subgroup'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, subGroup, testTranslations); + + const testItem1 = new SingleChoiceQuestionItem('test-survey.page1.subgroup.question1'); + const testItem2 = new SingleChoiceQuestionItem('test-survey.page1.subgroup.question2'); + const testItem3 = new DisplayItem('test-survey.page1.subgroup.display1'); + + editor.addItem({ parentKey: 'test-survey.page1.subgroup' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1.subgroup' }, testItem2, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1.subgroup' }, testItem3, testTranslations); + + const itemEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.subgroup.question1'); + const siblingKeys = itemEditor.getSiblingKeys(); + + expect(siblingKeys).toHaveLength(2); + expect(siblingKeys.map(key => key.fullKey)).toContain('test-survey.page1.subgroup.question2'); + expect(siblingKeys.map(key => key.fullKey)).toContain('test-survey.page1.subgroup.display1'); + expect(siblingKeys.map(key => key.fullKey)).not.toContain('test-survey.page1.subgroup.question1'); + }); + + test('should work with root level items by testing through a child item', () => { + // Add another root level group + const rootGroup2 = new GroupItem('test-survey2'); + const testTranslations = createTestTranslations(); + + editor.addItem(undefined, rootGroup2, testTranslations); + + // Add a page to the new root group + const subGroup = new GroupItem('test-survey2.page1'); + editor.addItem({ parentKey: 'test-survey2' }, subGroup, testTranslations); + + // Add a question to each root group's subpage + const testItem1 = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testItem2 = new SingleChoiceQuestionItem('test-survey2.page1.question1'); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey2.page1' }, testItem2, testTranslations); + + // Test that items in different root groups are not siblings + const itemEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + const siblingKeys = itemEditor.getSiblingKeys(); + + // Should have no siblings since it's the only item in test-survey.page1 + expect(siblingKeys).toHaveLength(0); + expect(siblingKeys.map(key => key.fullKey)).not.toContain('test-survey2.page1.question1'); + }); + + test('should return keys with correct properties', () => { + const testItem1 = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testItem2 = new SingleChoiceQuestionItem('test-survey.page1.question2'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + + const itemEditor = new SingleChoiceQuestionEditor(editor, 'test-survey.page1.question1'); + const siblingKeys = itemEditor.getSiblingKeys(); + + expect(siblingKeys).toHaveLength(1); + const siblingKey = siblingKeys[0]; + + expect(siblingKey.fullKey).toBe('test-survey.page1.question2'); + expect(siblingKey.itemKey).toBe('question2'); + expect(siblingKey.parentFullKey).toBe('test-survey.page1'); + expect(siblingKey.isRoot).toBe(false); + }); + }); +}); + +// New tests for enhanced SurveyEditor undo/redo functionality +describe('Enhanced SurveyEditor Undo/Redo', () => { + let survey: Survey; + let editor: SurveyEditor; + + beforeEach(() => { + survey = createTestSurvey(); + editor = new SurveyEditor(survey); + }); + + describe('Exposed UndoRedo Instance', () => { + test('should expose undoRedo instance', () => { + expect(editor.undoRedo).toBeDefined(); + expect(editor.undoRedo).toBeInstanceOf(SurveyEditorUndoRedo); + }); + + test('should provide access to history through undoRedo instance', () => { + // Add some items to build history + const testItem1 = new DisplayItem('test-survey.page1.display1'); + const testItem2 = new DisplayItem('test-survey.page1.display2'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + + const history = editor.undoRedo.getHistory(); + expect(history).toHaveLength(3); // Initial + 2 additions + expect(history[0].description).toBe('Initial state'); + expect(history[1].description).toBe('Added test-survey.page1.display1'); + expect(history[2].description).toBe('Added test-survey.page1.display2'); + expect(history[2].isCurrent).toBe(true); + }); + + test('should provide access to current index through undoRedo instance', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + expect(editor.undoRedo.getCurrentIndex()).toBe(1); + expect(editor.undoRedo.getHistoryLength()).toBe(2); + + editor.undo(); + expect(editor.undoRedo.getCurrentIndex()).toBe(0); + expect(editor.undoRedo.getHistoryLength()).toBe(2); + }); + + test('should allow direct navigation through undoRedo instance', () => { + const testItem1 = new DisplayItem('test-survey.page1.display1'); + const testItem2 = new DisplayItem('test-survey.page1.display2'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + + // Use undoRedo instance directly + const result = editor.undoRedo.jumpToIndex(0); + expect(result).toBeDefined(); + expect(editor.undoRedo.getCurrentIndex()).toBe(0); + + // But this doesn't update the editor's survey state + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.display2']).toBeDefined(); + }); + }); + + describe('Bulk Undo/Redo Methods', () => { + beforeEach(() => { + // Build some history + const testItem1 = new DisplayItem('test-survey.page1.display1'); + const testItem2 = new DisplayItem('test-survey.page1.display2'); + const testItem3 = new DisplayItem('test-survey.page1.display3'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem3, testTranslations); + }); + + test('should support jumpToIndex for backward navigation', () => { + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.display2']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.display3']).toBeDefined(); + + const success = editor.jumpToIndex(1); + expect(success).toBe(true); + expect(editor.undoRedo.getCurrentIndex()).toBe(1); + + // Should have only the first item + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.display2']).toBeUndefined(); + expect(editor.survey.surveyItems['test-survey.page1.display3']).toBeUndefined(); + }); + + test('should support jumpToIndex for forward navigation', () => { + // First jump to create forward navigation opportunity + editor.jumpToIndex(1); + + const success = editor.jumpToIndex(3); + expect(success).toBe(true); + expect(editor.undoRedo.getCurrentIndex()).toBe(3); + + // Should have all items back + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.display2']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.display3']).toBeDefined(); + }); + + test('should support jumpToIndex to initial state', () => { + const success = editor.jumpToIndex(0); + expect(success).toBe(true); + expect(editor.undoRedo.getCurrentIndex()).toBe(0); + + // Should have no additional items + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeUndefined(); + expect(editor.survey.surveyItems['test-survey.page1.display2']).toBeUndefined(); + expect(editor.survey.surveyItems['test-survey.page1.display3']).toBeUndefined(); + }); + + test('should return false for invalid indices', () => { + expect(editor.jumpToIndex(-1)).toBe(false); + expect(editor.jumpToIndex(3)).toBe(false); // Current index + expect(editor.jumpToIndex(10)).toBe(false); // Beyond history + }); + + test('should not allow navigation with uncommitted changes', () => { + // Make an uncommitted change + const updatedTranslations = createTestTranslations(); + editor.updateItemTranslations('test-survey.page1.display1', updatedTranslations); + + expect(editor.hasUncommittedChanges).toBe(true); + + // Should not allow navigation + expect(editor.jumpToIndex(1)).toBe(false); + }); + + test('should handle navigation after making changes', () => { + // Jump to middle of history + editor.jumpToIndex(1); + + // Make new changes + const newItem = new DisplayItem('test-survey.page1.new-display'); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, newItem, testTranslations); + + // History should be truncated + expect(editor.undoRedo.getHistoryLength()).toBe(3); // Initial + first item + new item + expect(editor.undoRedo.getCurrentIndex()).toBe(2); + + // Should have first and new items + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.display2']).toBeUndefined(); + expect(editor.survey.surveyItems['test-survey.page1.display3']).toBeUndefined(); + expect(editor.survey.surveyItems['test-survey.page1.new-display']).toBeDefined(); + }); + }); + + describe('Integration with Existing Functionality', () => { + test('should work with existing undo/redo methods', () => { + const testItem1 = new DisplayItem('test-survey.page1.display1'); + const testItem2 = new DisplayItem('test-survey.page1.display2'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + + // Jump to middle + editor.jumpToIndex(1); + + // Use existing methods + expect(editor.canUndo()).toBe(true); + expect(editor.canRedo()).toBe(true); + + const undoSuccess = editor.undo(); + expect(undoSuccess).toBe(true); + expect(editor.undoRedo.getCurrentIndex()).toBe(0); + + const redoSuccess = editor.redo(); + expect(redoSuccess).toBe(true); + expect(editor.undoRedo.getCurrentIndex()).toBe(1); + }); + + test('should maintain description consistency', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Check descriptions through both interfaces + expect(editor.getUndoDescription()).toBe('Added test-survey.page1.display1'); + expect(editor.undoRedo.getUndoDescription()).toBe('Added test-survey.page1.display1'); + + editor.undo(); + expect(editor.getRedoDescription()).toBe('Added test-survey.page1.display1'); + expect(editor.undoRedo.getRedoDescription()).toBe('Added test-survey.page1.display1'); + }); + + test('should handle complex operations with navigation', () => { + const testItem1 = new DisplayItem('test-survey.page1.display1'); + const testItem2 = new GroupItem('test-survey.page1.group1'); + const testItem3 = new DisplayItem('test-survey.page1.group1.display1'); + const testTranslations = createTestTranslations(); + + // Build nested structure + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1.group1' }, testItem3, testTranslations); + + // Navigate to middle + editor.jumpToIndex(1); + + // Only first item should exist + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.group1']).toBeUndefined(); + expect(editor.survey.surveyItems['test-survey.page1.group1.display1']).toBeUndefined(); + + // Move item (should commit and create new branch) + const moveItem = new DisplayItem('test-survey.page1.moved-display'); + editor.addItem({ parentKey: 'test-survey.page1' }, moveItem, testTranslations); + + // Should have new history + expect(editor.undoRedo.getHistoryLength()).toBe(3); + expect(editor.survey.surveyItems['test-survey.page1.display1']).toBeDefined(); + expect(editor.survey.surveyItems['test-survey.page1.moved-display']).toBeDefined(); + }); + }); + + describe('Memory and Performance', () => { + test('should maintain memory usage information', () => { + const initialUsage = editor.getMemoryUsage(); + + // Add items + for (let i = 0; i < 5; i++) { + const testItem = new DisplayItem(`test-survey.page1.display${i}`); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + } + + const afterUsage = editor.getMemoryUsage(); + expect(afterUsage.entries).toBeGreaterThan(initialUsage.entries); + expect(afterUsage.totalMB).toBeGreaterThan(initialUsage.totalMB); + + // Navigate around + editor.jumpToIndex(2); + editor.jumpToIndex(4); + + // Memory usage should remain the same + const navigationUsage = editor.getMemoryUsage(); + expect(navigationUsage.entries).toBe(afterUsage.entries); + expect(navigationUsage.totalMB).toBe(afterUsage.totalMB); + }); + + test('should handle large navigation operations efficiently', () => { + // Build larger history + for (let i = 0; i < 20; i++) { + const testItem = new DisplayItem(`test-survey.page1.display${i}`); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + } + + const startTime = performance.now(); + + // Perform various navigation operations + editor.jumpToIndex(0); + editor.jumpToIndex(10); + editor.jumpToIndex(5); + editor.jumpToIndex(15); + + const endTime = performance.now(); + const duration = endTime - startTime; + + // Should be very fast (less than 100ms) + expect(duration).toBeLessThan(100); + + // Should be at correct position + expect(editor.undoRedo.getCurrentIndex()).toBe(15); + }); + }); + + describe('Edge Cases', () => { + test('should handle navigation on empty history', () => { + // Create fresh editor + const freshSurvey = createTestSurvey(); + const freshEditor = new SurveyEditor(freshSurvey); + + expect(freshEditor.jumpToIndex(0)).toBe(false); + }); + + test('should handle navigation with only initial state', () => { + expect(editor.undoRedo.getHistoryLength()).toBe(1); + expect(editor.undoRedo.getCurrentIndex()).toBe(0); + + expect(editor.jumpToIndex(0)).toBe(false); + }); + + test('should handle uncommitted changes correctly', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Make uncommitted change + const updatedTranslations = createTestTranslations(); + editor.updateItemTranslations('test-survey.page1.display1', updatedTranslations); + + expect(editor.hasUncommittedChanges).toBe(true); + + // Navigation should fail + expect(editor.jumpToIndex(0)).toBe(false); + + // Commit changes + editor.commitIfNeeded(); + expect(editor.hasUncommittedChanges).toBe(false); + + // Navigation should work now + expect(editor.jumpToIndex(0)).toBe(true); + }); + }); + + describe('Serialization (toJson/fromJson)', () => { + test('should serialize basic editor state', () => { + const jsonData = editor.toJson(); + + expect(jsonData.version).toBe('1.0.0'); + expect(jsonData.survey).toBeDefined(); + expect(jsonData.undoRedo).toBeDefined(); + expect(jsonData.hasUncommittedChanges).toBe(false); + }); + + test('should preserve survey data in serialization', () => { + const jsonData = editor.toJson(); + + expect(jsonData.survey.surveyItems).toBeDefined(); + expect(jsonData.survey.surveyItems['test-survey']).toBeDefined(); + expect(jsonData.survey.surveyItems['test-survey.page1']).toBeDefined(); + expect(jsonData.survey.$schema).toBeDefined(); + }); + + test('should preserve undo/redo state in serialization', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const jsonData = editor.toJson(); + + expect(jsonData.undoRedo.history).toBeDefined(); + expect(jsonData.undoRedo.currentIndex).toBeDefined(); + expect(jsonData.undoRedo.config).toBeDefined(); + expect(jsonData.undoRedo.history.length).toBeGreaterThan(1); + }); + + test('should preserve uncommitted changes flag', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Make uncommitted changes + const updatedTranslations = createTestTranslations(); + editor.updateItemTranslations('test-survey.page1.display1', updatedTranslations); + + const jsonData = editor.toJson(); + expect(jsonData.hasUncommittedChanges).toBe(true); + }); + + test('should deserialize and create equivalent editor', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const jsonData = editor.toJson(); + const restoredEditor = SurveyEditor.fromJson(jsonData); + + expect(restoredEditor.survey.surveyKey).toBe(editor.survey.surveyKey); + expect(restoredEditor.hasUncommittedChanges).toBe(editor.hasUncommittedChanges); + expect(restoredEditor.canUndo()).toBe(editor.canUndo()); + expect(restoredEditor.canRedo()).toBe(editor.canRedo()); + }); + + test('should preserve all survey items during round-trip', () => { + const testItem1 = new DisplayItem('test-survey.page1.display1'); + const testItem2 = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testItem3 = new GroupItem('test-survey.page1.group1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem3, testTranslations); + + const jsonData = editor.toJson(); + const restoredEditor = SurveyEditor.fromJson(jsonData); + + // Check all items are preserved + expect(restoredEditor.survey.surveyItems['test-survey.page1.display1']).toBeDefined(); + expect(restoredEditor.survey.surveyItems['test-survey.page1.question1']).toBeDefined(); + expect(restoredEditor.survey.surveyItems['test-survey.page1.group1']).toBeDefined(); + + // Check item types are preserved + expect(restoredEditor.survey.surveyItems['test-survey.page1.display1'].itemType).toBe(SurveyItemType.Display); + expect(restoredEditor.survey.surveyItems['test-survey.page1.question1'].itemType).toBe(SurveyItemType.SingleChoiceQuestion); + expect(restoredEditor.survey.surveyItems['test-survey.page1.group1'].itemType).toBe(SurveyItemType.Group); + }); + + test('should preserve complete undo/redo history', () => { + const testItem1 = new DisplayItem('test-survey.page1.display1'); + const testItem2 = new DisplayItem('test-survey.page1.display2'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + + const originalHistoryLength = editor.undoRedo.getHistoryLength(); + const originalCurrentIndex = editor.undoRedo.getCurrentIndex(); + + const jsonData = editor.toJson(); + const restoredEditor = SurveyEditor.fromJson(jsonData); + + expect(restoredEditor.undoRedo.getHistoryLength()).toBe(originalHistoryLength); + expect(restoredEditor.undoRedo.getCurrentIndex()).toBe(originalCurrentIndex); + + // Test that undo/redo still works + expect(restoredEditor.undo()).toBe(true); + expect(restoredEditor.survey.surveyItems['test-survey.page1.display2']).toBeUndefined(); + + expect(restoredEditor.redo()).toBe(true); + expect(restoredEditor.survey.surveyItems['test-survey.page1.display2']).toBeDefined(); + }); + + test('should preserve uncommitted changes state', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Make uncommitted changes + const updatedTranslations = createTestTranslations(); + editor.updateItemTranslations('test-survey.page1.display1', updatedTranslations); + + const jsonData = editor.toJson(); + const restoredEditor = SurveyEditor.fromJson(jsonData); + + expect(restoredEditor.hasUncommittedChanges).toBe(true); + expect(restoredEditor.canRedo()).toBe(false); // Should not be able to redo with uncommitted changes + }); + + test('should handle round-trip consistency', () => { + const testItem1 = new DisplayItem('test-survey.page1.display1'); + const testItem2 = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + + const originalJson = editor.toJson(); + const restoredEditor = SurveyEditor.fromJson(originalJson); + const secondJson = restoredEditor.toJson(); + + // The JSON should be identical (except for potential timestamp differences in history) + expect(secondJson.version).toBe(originalJson.version); + expect(secondJson.hasUncommittedChanges).toBe(originalJson.hasUncommittedChanges); + expect(secondJson.undoRedo.currentIndex).toBe(originalJson.undoRedo.currentIndex); + expect(secondJson.undoRedo.history.length).toBe(originalJson.undoRedo.history.length); + + // Survey data should be identical + expect(JSON.stringify(secondJson.survey)).toBe(JSON.stringify(originalJson.survey)); + }); + + test('should handle empty editor serialization', () => { + const freshSurvey = createTestSurvey(); + const freshEditor = new SurveyEditor(freshSurvey); + + const jsonData = freshEditor.toJson(); + const restoredEditor = SurveyEditor.fromJson(jsonData); + + expect(restoredEditor.survey.surveyKey).toBe(freshEditor.survey.surveyKey); + expect(restoredEditor.hasUncommittedChanges).toBe(false); + expect(restoredEditor.canUndo()).toBe(false); + expect(restoredEditor.canRedo()).toBe(false); + }); + + test('should handle editor with complex expressions', () => { + const testItem = new DisplayItem('test-survey.page1.display1'); + const testTranslations = createTestTranslations(); + + // Add item with complex expression + testItem.displayConditions = { + root: createComplexTestExpression() + }; + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const jsonData = editor.toJson(); + const restoredEditor = SurveyEditor.fromJson(jsonData); + + const restoredItem = restoredEditor.survey.surveyItems['test-survey.page1.display1'] as DisplayItem; + expect(restoredItem.displayConditions?.root).toBeDefined(); + expect(restoredItem.displayConditions?.root?.toJson()).toEqual(createComplexTestExpression().toJson()); + }); + + describe('Error Handling', () => { + test('should throw error for missing survey data', () => { + const invalidJson = { + version: '1.0.0', + undoRedo: editor.undoRedo.toJSON(), + hasUncommittedChanges: false + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + expect(() => SurveyEditor.fromJson(invalidJson)).toThrow('Invalid JSON data: survey is required'); + }); + + test('should throw error for missing undo/redo data', () => { + const invalidJson = { + version: '1.0.0', + survey: editor.survey.toJson(), + hasUncommittedChanges: false + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + expect(() => SurveyEditor.fromJson(invalidJson)).toThrow('Invalid JSON data: undoRedo is required'); + }); + + test('should throw error for invalid hasUncommittedChanges', () => { + const invalidJson = { + version: '1.0.0', + survey: editor.survey.toJson(), + undoRedo: editor.undoRedo.toJSON(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + hasUncommittedChanges: 'invalid' as any + }; + + expect(() => SurveyEditor.fromJson(invalidJson)).toThrow('Invalid JSON data: hasUncommittedChanges must be a boolean'); + }); + + test('should handle invalid survey data gracefully', () => { + const invalidJson = { + version: '1.0.0', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + survey: { invalid: 'data' } as any, + undoRedo: editor.undoRedo.toJSON(), + hasUncommittedChanges: false + }; + + expect(() => SurveyEditor.fromJson(invalidJson)).toThrow(); + }); + + test('should handle invalid undo/redo data gracefully', () => { + const invalidJson = { + version: '1.0.0', + survey: editor.survey.toJson(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + undoRedo: { invalid: 'data' } as any, + hasUncommittedChanges: false + }; + + expect(() => SurveyEditor.fromJson(invalidJson)).toThrow(); + }); + }); + + describe('Version Compatibility', () => { + test('should warn for future version but still load', () => { + const futureVersionJson = { + version: '2.0.0', + survey: editor.survey.toJson(), + undoRedo: editor.undoRedo.toJSON(), + hasUncommittedChanges: false + }; + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const restoredEditor = SurveyEditor.fromJson(futureVersionJson); + + expect(consoleSpy).toHaveBeenCalledWith('Warning: Loading SurveyEditor with version 2.0.0, current version is 1.0.0'); + expect(restoredEditor.survey.surveyKey).toBe(editor.survey.surveyKey); + + consoleSpy.mockRestore(); + }); + + test('should handle missing version gracefully', () => { + const noVersionJson = { + survey: editor.survey.toJson(), + undoRedo: editor.undoRedo.toJSON(), + hasUncommittedChanges: false + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const restoredEditor = SurveyEditor.fromJson(noVersionJson); + expect(restoredEditor.survey.surveyKey).toBe(editor.survey.surveyKey); + }); + + test('should handle compatible version without warning', () => { + const compatibleVersionJson = { + version: '1.1.0', + survey: editor.survey.toJson(), + undoRedo: editor.undoRedo.toJSON(), + hasUncommittedChanges: false + }; + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const restoredEditor = SurveyEditor.fromJson(compatibleVersionJson); + + expect(consoleSpy).not.toHaveBeenCalled(); + expect(restoredEditor.survey.surveyKey).toBe(editor.survey.surveyKey); + + consoleSpy.mockRestore(); + }); + }); + + describe('Memory and Performance', () => { + test('should handle large editor state serialization', () => { + // Create large editor state + for (let i = 0; i < 50; i++) { + const testItem = new DisplayItem(`test-survey.page1.display${i}`); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + } + + const startTime = performance.now(); + const jsonData = editor.toJson(); + const serializationTime = performance.now() - startTime; + + const deserializationStartTime = performance.now(); + const restoredEditor = SurveyEditor.fromJson(jsonData); + const deserializationTime = performance.now() - deserializationStartTime; + + // Should be reasonably fast (less than 1 second each) + expect(serializationTime).toBeLessThan(1000); + expect(deserializationTime).toBeLessThan(1000); + + // Should preserve all items + expect(Object.keys(restoredEditor.survey.surveyItems)).toHaveLength(Object.keys(editor.survey.surveyItems).length); + }); + + test('should handle memory usage information preservation', () => { + // Add some items to increase memory usage + for (let i = 0; i < 10; i++) { + const testItem = new DisplayItem(`test-survey.page1.display${i}`); + const testTranslations = createTestTranslations(); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + } + + const originalMemoryUsage = editor.getMemoryUsage(); + const jsonData = editor.toJson(); + const restoredEditor = SurveyEditor.fromJson(jsonData); + const restoredMemoryUsage = restoredEditor.getMemoryUsage(); + + expect(restoredMemoryUsage.entries).toBe(originalMemoryUsage.entries); + expect(restoredMemoryUsage.totalMB).toBeCloseTo(originalMemoryUsage.totalMB, 2); + }); + }); + }); + + describe('Event Listener Functionality', () => { + let eventSpy: jest.Mock; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let eventData: any; + + beforeEach(() => { + eventSpy = jest.fn(); + eventData = null; + + // Setup event listener to capture data + editor.on('survey-changed', (data) => { + eventSpy(data); + eventData = data; + }); + }); + + afterEach(() => { + editor.clearAllListeners(); + }); + + describe('Basic Event Listener Management', () => { + test('should register and trigger event listeners', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + expect(eventSpy).toHaveBeenCalled(); + expect(eventData).toBeDefined(); + expect(eventData.isCommit).toBe(true); + expect(eventData.hasUncommittedChanges).toBe(false); + expect(eventData.description).toBe('Added test-survey.page1.item1'); + }); + + test('should allow multiple listeners for the same event', () => { + const secondSpy = jest.fn(); + const thirdSpy = jest.fn(); + + editor.on('survey-changed', secondSpy); + editor.on('survey-changed', thirdSpy); + + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + expect(eventSpy).toHaveBeenCalledTimes(1); + expect(secondSpy).toHaveBeenCalledTimes(1); + expect(thirdSpy).toHaveBeenCalledTimes(1); + }); + + test('should remove specific listeners', () => { + const secondSpy = jest.fn(); + editor.on('survey-changed', secondSpy); + + editor.off('survey-changed', secondSpy); + + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + expect(eventSpy).toHaveBeenCalledTimes(1); + expect(secondSpy).not.toHaveBeenCalled(); + }); + + test('should clear all listeners', () => { + const secondSpy = jest.fn(); + editor.on('survey-changed', secondSpy); + + editor.clearAllListeners(); + + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + expect(eventSpy).not.toHaveBeenCalled(); + expect(secondSpy).not.toHaveBeenCalled(); + }); + + test('should return correct listener count', () => { + expect(editor.getListenerCount()).toBe(1); // One listener from beforeEach + + const secondSpy = jest.fn(); + editor.on('survey-changed', secondSpy); + + expect(editor.getListenerCount()).toBe(2); + expect(editor.getListenerCount('survey-changed')).toBe(2); + + editor.off('survey-changed', secondSpy); + + expect(editor.getListenerCount()).toBe(1); + expect(editor.getListenerCount('survey-changed')).toBe(1); + }); + }); + + describe('Event Emission for Modifications', () => { + test('should emit event when making uncommitted changes', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Reset spy to focus on next event + eventSpy.mockClear(); + + // Make an uncommitted change + const updatedTranslations = createTestTranslations(); + updatedTranslations.setContent('es', 'title', { type: ContentType.md, content: 'Contenido de prueba' }); + + editor.updateItemTranslations('test-survey.page1.item1', updatedTranslations); + + expect(eventSpy).toHaveBeenCalledWith({ + hasUncommittedChanges: true, + isCommit: false + }); + }); + + test('should emit event when committing changes', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Reset spy to focus on next event + eventSpy.mockClear(); + + // Make an uncommitted change + const updatedTranslations = createTestTranslations(); + editor.updateItemTranslations('test-survey.page1.item1', updatedTranslations); + + // Reset spy to focus on commit event + eventSpy.mockClear(); + + // Commit the changes + editor.commit('Manual commit'); + + expect(eventSpy).toHaveBeenCalledWith({ + hasUncommittedChanges: false, + isCommit: true, + description: 'Manual commit' + }); + }); + }); + + describe('Event Emission for Operations', () => { + test('should emit event when adding items', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + expect(eventSpy).toHaveBeenCalledWith({ + hasUncommittedChanges: false, + isCommit: true, + description: 'Added test-survey.page1.item1' + }); + }); + + test('should emit event when removing items', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Reset spy to focus on remove event + eventSpy.mockClear(); + + editor.removeItem('test-survey.page1.item1'); + + expect(eventSpy).toHaveBeenCalledWith({ + hasUncommittedChanges: false, + isCommit: true, + description: 'Removed test-survey.page1.item1' + }); + }); + + test('should emit event when moving items', () => { + const testItem1 = new DisplayItem('test-survey.page1.item1'); + const testItem2 = new DisplayItem('test-survey.page1.item2'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem1, testTranslations); + editor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + + // Create a second group to move item to + const subGroup2 = new GroupItem('test-survey.page2'); + editor.addItem({ parentKey: 'test-survey' }, subGroup2, testTranslations); + + // Reset spy to focus on move event + eventSpy.mockClear(); + + editor.moveItem('test-survey.page1.item1', { parentKey: 'test-survey.page2' }); + + expect(eventSpy).toHaveBeenCalledWith({ + hasUncommittedChanges: false, + isCommit: true, + description: 'Moved test-survey.page1.item1 to test-survey.page2' + }); + }); + + test('should emit event when renaming items', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Reset spy to focus on rename event + eventSpy.mockClear(); + + editor.onItemKeyChanged('test-survey.page1.item1', 'test-survey.page1.renamedItem'); + + expect(eventSpy).toHaveBeenCalledWith({ + hasUncommittedChanges: false, + isCommit: true, + description: 'Renamed test-survey.page1.item1 to test-survey.page1.renamedItem' + }); + }); + + test('should emit event when renaming components', () => { + const testItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Reset spy to focus on component rename event + eventSpy.mockClear(); + + editor.onComponentKeyChanged('test-survey.page1.question1', 'oldKey', 'newKey'); + + expect(eventSpy).toHaveBeenCalledWith({ + hasUncommittedChanges: false, + isCommit: true, + description: 'Renamed component oldKey to newKey in test-survey.page1.question1' + }); + }); + + test('should emit event when deleting components', () => { + const testItem = new SingleChoiceQuestionItem('test-survey.page1.question1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Reset spy to focus on component delete event + eventSpy.mockClear(); + + editor.deleteComponent('test-survey.page1.question1', 'someComponent'); + + expect(eventSpy).toHaveBeenCalledWith({ + hasUncommittedChanges: false, + isCommit: true, + description: 'Deleted component someComponent from test-survey.page1.question1' + }); + }); + }); + + describe('Event Emission for Undo/Redo Operations', () => { + test('should emit events for undo operations', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Reset spy to focus on undo + eventSpy.mockClear(); + + editor.undo(); + + // Undo should not emit events as per the simplified design + expect(eventSpy).toHaveBeenCalled(); + }); + + test('should emit events for redo operations', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + editor.undo(); + + // Reset spy to focus on redo + eventSpy.mockClear(); + + editor.redo(); + + // Redo should not emit events as per the simplified design + expect(eventSpy).toHaveBeenCalled(); + }); + + test('should emit events for jumpToIndex operations', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Reset spy to focus on jumpToIndex + eventSpy.mockClear(); + + editor.jumpToIndex(0); + + // jumpToIndex should not emit events as per the simplified design + expect(eventSpy).toHaveBeenCalled(); + }); + }); + + describe('Error Handling', () => { + test('should handle listener errors gracefully', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Add a listener that throws an error + editor.on('survey-changed', () => { + throw new Error('Test error'); + }); + + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + // This should not throw, but should log the error + expect(() => { + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + }).not.toThrow(); + + expect(errorSpy).toHaveBeenCalledWith('Error in survey-changed listener:', expect.any(Error)); + + // The original listener should still have been called + expect(eventSpy).toHaveBeenCalled(); + + errorSpy.mockRestore(); + }); + + test('should handle removing non-existent listeners gracefully', () => { + const nonExistentSpy = jest.fn(); + + // This should not throw + expect(() => { + editor.off('survey-changed', nonExistentSpy); + }).not.toThrow(); + + // Should not affect existing listeners + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + expect(eventSpy).toHaveBeenCalled(); + }); + }); + + describe('Listener State After fromJson', () => { + test('should not preserve listeners after fromJson restoration', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + const jsonData = editor.toJson(); + const restoredEditor = SurveyEditor.fromJson(jsonData); + + // Should have no listeners after restoration + expect(restoredEditor.getListenerCount()).toBe(0); + + // Adding a listener to the restored editor should work + const restoredSpy = jest.fn(); + restoredEditor.on('survey-changed', restoredSpy); + + const testItem2 = new DisplayItem('test-survey.page1.item2'); + restoredEditor.addItem({ parentKey: 'test-survey.page1' }, testItem2, testTranslations); + + expect(restoredSpy).toHaveBeenCalled(); + }); + }); + + describe('Event Data Structure Validation', () => { + test('should provide consistent event data structure for modifications', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + // Reset spy to focus on modification event + eventSpy.mockClear(); + + // Make an uncommitted change + const updatedTranslations = createTestTranslations(); + editor.updateItemTranslations('test-survey.page1.item1', updatedTranslations); + + expect(eventSpy).toHaveBeenCalledWith({ + hasUncommittedChanges: true, + isCommit: false + }); + + // Verify the structure doesn't contain description for non-commits + expect(eventData.description).toBeUndefined(); + }); + + test('should provide consistent event data structure for commits', () => { + const testItem = new DisplayItem('test-survey.page1.item1'); + const testTranslations = createTestTranslations(); + + editor.addItem({ parentKey: 'test-survey.page1' }, testItem, testTranslations); + + expect(eventSpy).toHaveBeenCalledWith({ + hasUncommittedChanges: false, + isCommit: true, + description: 'Added test-survey.page1.item1' + }); + + // Verify all expected properties are present + expect(eventData.hasUncommittedChanges).toBe(false); + expect(eventData.isCommit).toBe(true); + expect(eventData.description).toBe('Added test-survey.page1.item1'); + }); + }); + }); +}); diff --git a/src/__tests__/translations.test.ts b/src/__tests__/translations.test.ts new file mode 100644 index 0000000..710a433 --- /dev/null +++ b/src/__tests__/translations.test.ts @@ -0,0 +1,932 @@ +import { SurveyItemTranslations, SurveyTranslations, JsonSurveyCardContent } from '../survey/utils/translations'; +import { Content, ContentType } from '../survey/utils/content'; + +// Mock content for testing +const mockContent: Content = { + type: ContentType.md, + content: 'Test content' +}; + +const mockCQMContent: Content = { + type: ContentType.CQM, + content: 'CQM test content', + attributions: [] +}; + +const mockSurveyCardContent: JsonSurveyCardContent = { + name: mockContent, + description: mockContent, + typicalDuration: mockContent +}; + +const enLocale = 'en'; +const deLocale = 'de'; + +describe('SurveyItemTranslations', () => { + let itemTranslations: SurveyItemTranslations; + + beforeEach(() => { + itemTranslations = new SurveyItemTranslations(); + }); + + describe('constructor', () => { + test('should initialize with empty translations', () => { + expect(itemTranslations.locales).toEqual([]); + }); + }); + + describe('setContent', () => { + test('should set content for a new locale and contentKey', () => { + itemTranslations.setContent(enLocale, 'title', mockContent); + + expect(itemTranslations.locales).toContain('en'); + expect(itemTranslations.getContent(enLocale, 'title')).toEqual(mockContent); + }); + + test('should update existing content', () => { + itemTranslations.setContent(enLocale, 'title', mockContent); + itemTranslations.setContent(enLocale, 'title', mockCQMContent); + + expect(itemTranslations.getContent(enLocale, 'title')).toEqual(mockCQMContent); + }); + + test('should add content to existing locale', () => { + itemTranslations.setContent(enLocale, 'title', mockContent); + itemTranslations.setContent(enLocale, 'description', mockCQMContent); + + expect(itemTranslations.getContent(enLocale, 'title')).toEqual(mockContent); + expect(itemTranslations.getContent(enLocale, 'description')).toEqual(mockCQMContent); + }); + + test('should not create locale when content is undefined', () => { + itemTranslations.setContent(enLocale, 'title', undefined); + + expect(itemTranslations.locales).toEqual([]); + }); + + test('should delete content when setting to undefined', () => { + itemTranslations.setContent(enLocale, 'title', mockContent); + itemTranslations.setContent(enLocale, 'title', undefined); + + expect(itemTranslations.getContent(enLocale, 'title')).toBeUndefined(); + expect(itemTranslations.locales).toContain(enLocale); // locale should still exist + }); + }); + + describe('setContentForLocale', () => { + test('should set complete content for a locale', () => { + const localeContent = { + title: mockContent, + description: mockCQMContent + }; + + itemTranslations.setAllForLocale(enLocale, localeContent); + + expect(itemTranslations.getAllForLocale(enLocale)).toEqual(localeContent); + expect(itemTranslations.getContent(enLocale, 'title')).toEqual(mockContent); + expect(itemTranslations.getContent(enLocale, 'description')).toEqual(mockCQMContent); + }); + + test('should replace existing locale content', () => { + itemTranslations.setContent(enLocale, 'title', mockContent); + + const newLocaleContent = { + description: mockCQMContent + }; + + itemTranslations.setAllForLocale(enLocale, newLocaleContent); + + expect(itemTranslations.getAllForLocale(enLocale)).toEqual(newLocaleContent); + expect(itemTranslations.getContent(enLocale, 'title')).toBeUndefined(); + expect(itemTranslations.getContent(enLocale, 'description')).toEqual(mockCQMContent); + }); + + test('should not create locale when content is undefined', () => { + itemTranslations.setAllForLocale(enLocale, undefined); + + expect(itemTranslations.locales).toEqual([]); + }); + + test('should set empty object when content is undefined for existing locale', () => { + itemTranslations.setContent(enLocale, 'title', mockContent); + itemTranslations.setAllForLocale(enLocale, undefined); + + expect(itemTranslations.getAllForLocale(enLocale)).toEqual({}); + }); + }); + + describe('getters', () => { + test('should return all locales', () => { + itemTranslations.setContent(enLocale, 'title', mockContent); + itemTranslations.setContent(deLocale, 'title', mockContent); + itemTranslations.setContent('fr', 'description', mockContent); + + expect(itemTranslations.locales).toContain(enLocale); + expect(itemTranslations.locales).toContain('de'); + expect(itemTranslations.locales).toContain('fr'); + expect(itemTranslations.locales).toHaveLength(3); + }); + + test('should return undefined for non-existent locale content', () => { + expect(itemTranslations.getAllForLocale('nonexistent')).toBeUndefined(); + }); + + test('should return undefined for non-existent content key', () => { + itemTranslations.setContent(enLocale, 'title', mockContent); + expect(itemTranslations.getContent(enLocale, 'nonexistent')).toBeUndefined(); + }); + + test('should not allow empty strings as locale keys', () => { + expect(() => { + itemTranslations.setContent('', 'title', mockContent); + }).toThrow('Locale cannot be empty'); + + expect(() => { + itemTranslations.setAllForLocale('', { title: mockContent }); + }).toThrow('Locale cannot be empty'); + }); + }); +}); + +describe('SurveyTranslations', () => { + let surveyTranslations: SurveyTranslations; + + beforeEach(() => { + surveyTranslations = new SurveyTranslations(); + }); + + describe('constructor', () => { + test('should initialize with empty translations', () => { + expect(surveyTranslations.locales).toEqual([]); + }); + + test('should initialize with provided translations', () => { + const initialTranslations = { + en: { + surveyCardContent: mockSurveyCardContent, + 'item1': { title: mockContent } + } + }; + + const translations = new SurveyTranslations(initialTranslations); + + expect(translations.locales).toContain(enLocale); + expect(translations.surveyCardContent).toEqual({ en: mockSurveyCardContent }); + }); + }); + + describe('toJson', () => { + test('should return undefined when no translations exist', () => { + expect(surveyTranslations.toJson()).toBeUndefined(); + }); + + test('should return translations object when translations exist', () => { + surveyTranslations.setSurveyCardContent(enLocale, mockSurveyCardContent); + + const result = surveyTranslations.toJson(); + + expect(result).toBeDefined(); + expect(result!.en.surveyCardContent).toEqual(mockSurveyCardContent); + }); + }); + + describe('locale management', () => { + test('should remove locale', () => { + surveyTranslations.setSurveyCardContent(enLocale, mockSurveyCardContent); + surveyTranslations.setSurveyCardContent(deLocale, mockSurveyCardContent); + + expect(surveyTranslations.locales).toContain(enLocale); + expect(surveyTranslations.locales).toContain(deLocale); + + surveyTranslations.removeLocale(enLocale); + + expect(surveyTranslations.locales).not.toContain(enLocale); + expect(surveyTranslations.locales).toContain(deLocale); + }); + + test('should rename locale', () => { + const newLocale = 'en-US'; + surveyTranslations.setSurveyCardContent(enLocale, mockSurveyCardContent); + + surveyTranslations.renameLocale(enLocale, newLocale); + + expect(surveyTranslations.locales).not.toContain(enLocale); + expect(surveyTranslations.locales).toContain(newLocale); + expect(surveyTranslations.surveyCardContent![newLocale]).toEqual(mockSurveyCardContent); + }); + + test('should not rename non-existent locale', () => { + const newLocale = 'new'; + surveyTranslations.renameLocale('nonexistent', newLocale); + + expect(surveyTranslations.locales).not.toContain(newLocale); + }); + + test('should clone locale', () => { + const newLocale = 'en-US'; + surveyTranslations.setSurveyCardContent(enLocale, mockSurveyCardContent); + + surveyTranslations.cloneLocaleAs(enLocale, newLocale); + + expect(surveyTranslations.locales).toContain(enLocale); + expect(surveyTranslations.locales).toContain(newLocale); + expect(surveyTranslations.surveyCardContent![newLocale]).toEqual(mockSurveyCardContent); + }); + + test('should not clone non-existent locale', () => { + const newLocale = 'new'; + surveyTranslations.cloneLocaleAs('nonexistent', newLocale); + + expect(surveyTranslations.locales).not.toContain(newLocale); + }); + }); + + describe('survey card content', () => { + test('should set survey card content', () => { + surveyTranslations.setSurveyCardContent(enLocale, mockSurveyCardContent); + + expect(surveyTranslations.surveyCardContent).toEqual({ [enLocale]: mockSurveyCardContent }); + }); + + test('should not create locale when content is undefined', () => { + surveyTranslations.setSurveyCardContent(enLocale, undefined); + + expect(surveyTranslations.locales).toEqual([]); + }); + + test('should update existing survey card content', () => { + surveyTranslations.setSurveyCardContent(enLocale, mockSurveyCardContent); + + const newContent: JsonSurveyCardContent = { + name: mockCQMContent + }; + + surveyTranslations.setSurveyCardContent(enLocale, newContent); + + expect(surveyTranslations.surveyCardContent![enLocale]).toEqual(newContent); + }); + + test('should return undefined when no survey card content exists', () => { + expect(surveyTranslations.surveyCardContent).toEqual({}); + }); + }); + + describe('item translations', () => { + test('should get item translations', () => { + // First need to create locales in the survey + surveyTranslations.setSurveyCardContent(enLocale, mockSurveyCardContent); + surveyTranslations.setSurveyCardContent(deLocale, mockSurveyCardContent); + + const itemTranslations = new SurveyItemTranslations(); + itemTranslations.setContent(enLocale, 'title', mockContent); + itemTranslations.setContent(deLocale, 'title', mockCQMContent); + + surveyTranslations.setItemTranslations('item1', itemTranslations); + + const retrieved = surveyTranslations.getItemTranslations('item1'); + + expect(retrieved!.getContent(enLocale, 'title')).toEqual(mockContent); + expect(retrieved!.getContent(deLocale, 'title')).toEqual(mockCQMContent); + }); + + test('should set item translations', () => { + // First need to create the locale in the survey + surveyTranslations.setSurveyCardContent(enLocale, mockSurveyCardContent); + + const itemTranslations = new SurveyItemTranslations(); + itemTranslations.setContent(enLocale, 'title', mockContent); + + surveyTranslations.setItemTranslations('item1', itemTranslations); + + expect(surveyTranslations.locales).toContain('en'); + + const result = surveyTranslations.toJson(); + expect(result!.en.item1).toEqual({ title: mockContent }); + }); + + test('should remove item translations when set to undefined', () => { + // First need to create the locale in the survey + surveyTranslations.setSurveyCardContent(enLocale, mockSurveyCardContent); + + const itemTranslations = new SurveyItemTranslations(); + itemTranslations.setContent(enLocale, 'title', mockContent); + + surveyTranslations.setItemTranslations('item1', itemTranslations); + surveyTranslations.setItemTranslations('item1', undefined); + + const result = surveyTranslations.toJson(); + expect(result!.en.item1).toBeUndefined(); + }); + + test('should handle multiple locales in item translations', () => { + const itemTranslations = new SurveyItemTranslations(); + itemTranslations.setContent(enLocale, 'title', mockContent); + itemTranslations.setContent(deLocale, 'title', mockCQMContent); + + // Ensure survey has both locales + surveyTranslations.setSurveyCardContent(enLocale, mockSurveyCardContent); + surveyTranslations.setSurveyCardContent(deLocale, mockSurveyCardContent); + + surveyTranslations.setItemTranslations('item1', itemTranslations); + + const result = surveyTranslations.toJson(); + expect(result!.en.item1).toEqual({ title: mockContent }); + expect(result!.de.item1).toEqual({ title: mockCQMContent }); + }); + + test('should create new locales when item translations are added with non-existing locales', () => { + // Initially no locales exist + expect(surveyTranslations.locales).toEqual([]); + + const itemTranslations = new SurveyItemTranslations(); + itemTranslations.setContent(enLocale, 'title', mockContent); + itemTranslations.setContent('fr', 'title', mockCQMContent); + itemTranslations.setContent('es', 'description', mockContent); + + surveyTranslations.setItemTranslations('item1', itemTranslations); + + // Check that new locales were created + expect(surveyTranslations.locales).toContain('en'); + expect(surveyTranslations.locales).toContain('fr'); + expect(surveyTranslations.locales).toContain('es'); + expect(surveyTranslations.locales).toHaveLength(3); + + // Verify translations are accessible + const result = surveyTranslations.toJson(); + expect(result!.en.item1).toEqual({ title: mockContent }); + expect(result!.fr.item1).toEqual({ title: mockCQMContent }); + expect(result!.es.item1).toEqual({ description: mockContent }); + }); + }); + + describe('deletion methods', () => { + beforeEach(() => { + // First create the locale in the survey + surveyTranslations.setSurveyCardContent(enLocale, mockSurveyCardContent); + + const itemTranslations = new SurveyItemTranslations(); + itemTranslations.setContent(enLocale, 'comp1.title', mockContent); + itemTranslations.setContent(enLocale, 'comp1.description', mockCQMContent); + itemTranslations.setContent(enLocale, 'comp2.title', mockContent); + itemTranslations.setContent(enLocale, 'other', mockContent); + + surveyTranslations.setItemTranslations('item1', itemTranslations); + }); + + test('should delete component translations', () => { + surveyTranslations.onComponentDeleted('item1', 'comp1'); + + const retrieved = surveyTranslations.getItemTranslations('item1'); + + expect(retrieved!.getContent(enLocale, 'comp1.title')).toBeUndefined(); + expect(retrieved!.getContent(enLocale, 'comp1.description')).toBeUndefined(); + expect(retrieved!.getContent(enLocale, 'comp2.title')).toEqual(mockContent); + expect(retrieved!.getContent(enLocale, 'other')).toEqual(mockContent); + }); + + test('should delete exact component key', () => { + // Create a new survey translations and locale for this specific test + const testSurvey = new SurveyTranslations(); + testSurvey.setSurveyCardContent(enLocale, mockSurveyCardContent); + + const itemTranslations = new SurveyItemTranslations(); + itemTranslations.setContent(enLocale, 'comp1', mockContent); + itemTranslations.setContent(enLocale, 'comp1.sub', mockCQMContent); + + testSurvey.setItemTranslations('item1', itemTranslations); + testSurvey.onComponentDeleted('item1', 'comp1'); + + const retrieved = testSurvey.getItemTranslations('item1'); + + expect(retrieved!.getContent(enLocale, 'comp1')).toBeUndefined(); + expect(retrieved!.getContent(enLocale, 'comp1.sub')).toBeUndefined(); + }); + + test('should delete entire item translations', () => { + surveyTranslations.onItemDeleted('item1'); + + const result = surveyTranslations.toJson(); + expect(result!.en.item1).toBeUndefined(); + }); + + test('should delete all child item translations when group is deleted', () => { + // Create a new survey for this test with more complex structure + const testSurvey = new SurveyTranslations(); + testSurvey.setSurveyCardContent(enLocale, mockSurveyCardContent); + testSurvey.setSurveyCardContent(deLocale, mockSurveyCardContent); + + // Set up translations for a group and its nested items + const groupTranslations = new SurveyItemTranslations(); + groupTranslations.setContent(enLocale, 'title', mockContent); + groupTranslations.setContent(deLocale, 'title', mockCQMContent); + + const childItem1Translations = new SurveyItemTranslations(); + childItem1Translations.setContent(enLocale, 'comp1.title', mockContent); + childItem1Translations.setContent(deLocale, 'comp1.title', mockCQMContent); + + const childItem2Translations = new SurveyItemTranslations(); + childItem2Translations.setContent(enLocale, 'comp2.description', mockContent); + childItem2Translations.setContent(deLocale, 'comp2.description', mockCQMContent); + + const nestedGroupTranslations = new SurveyItemTranslations(); + nestedGroupTranslations.setContent(enLocale, 'nested.title', mockContent); + + const nestedChildTranslations = new SurveyItemTranslations(); + nestedChildTranslations.setContent(enLocale, 'deep.comp.text', mockContent); + + // Set up unrelated item that should not be affected + const unrelatedTranslations = new SurveyItemTranslations(); + unrelatedTranslations.setContent(enLocale, 'unrelated.title', mockContent); + unrelatedTranslations.setContent(deLocale, 'unrelated.title', mockCQMContent); + + // Add all translations to the survey + testSurvey.setItemTranslations('survey.group1', groupTranslations); + testSurvey.setItemTranslations('survey.group1.item1', childItem1Translations); + testSurvey.setItemTranslations('survey.group1.item2', childItem2Translations); + testSurvey.setItemTranslations('survey.group1.nestedGroup', nestedGroupTranslations); + testSurvey.setItemTranslations('survey.group1.nestedGroup.deepItem', nestedChildTranslations); + testSurvey.setItemTranslations('survey.group1unrelatedItem', unrelatedTranslations); + + // Verify all translations exist before deletion + const beforeDeletion = testSurvey.toJson(); + expect(beforeDeletion!.en['survey.group1']).toBeDefined(); + expect(beforeDeletion!.en['survey.group1.item1']).toBeDefined(); + expect(beforeDeletion!.en['survey.group1.item2']).toBeDefined(); + expect(beforeDeletion!.en['survey.group1.nestedGroup']).toBeDefined(); + expect(beforeDeletion!.en['survey.group1.nestedGroup.deepItem']).toBeDefined(); + expect(beforeDeletion!.en['survey.group1unrelatedItem']).toBeDefined(); + + // Delete the group + testSurvey.onItemDeleted('survey.group1'); + + // Verify all group and child item translations are removed + const afterDeletion = testSurvey.toJson(); + expect(afterDeletion!.en['survey.group1']).toBeUndefined(); + expect(afterDeletion!.en['survey.group1.item1']).toBeUndefined(); + expect(afterDeletion!.en['survey.group1.item2']).toBeUndefined(); + expect(afterDeletion!.en['survey.group1.nestedGroup']).toBeUndefined(); + expect(afterDeletion!.en['survey.group1.nestedGroup.deepItem']).toBeUndefined(); + + // Verify all locales are cleaned up + expect(afterDeletion!.de['survey.group1']).toBeUndefined(); + expect(afterDeletion!.de['survey.group1.item1']).toBeUndefined(); + expect(afterDeletion!.de['survey.group1.item2']).toBeUndefined(); + + // Verify unrelated item translations remain intact + expect(afterDeletion!.en['survey.group1unrelatedItem']).toBeDefined(); + expect(afterDeletion!.de['survey.group1unrelatedItem']).toBeDefined(); + expect(afterDeletion!.en['survey.group1unrelatedItem']).toEqual({ 'unrelated.title': mockContent }); + expect(afterDeletion!.de['survey.group1unrelatedItem']).toEqual({ 'unrelated.title': mockCQMContent }); + }); + + test('should handle deletion of non-existent item', () => { + surveyTranslations.onItemDeleted('nonexistent'); + + // Should not throw and should not affect existing data + const retrieved = surveyTranslations.getItemTranslations('item1'); + expect(retrieved!.getContent(enLocale, 'comp1.title')).toEqual(mockContent); + }); + + test('should handle deletion of non-existent component', () => { + surveyTranslations.onComponentDeleted('item1', 'nonexistent'); + + // Should not throw and should not affect existing data + const retrieved = surveyTranslations.getItemTranslations('item1'); + expect(retrieved!.getContent(enLocale, 'comp1.title')).toEqual(mockContent); + }); + }); + + describe('item key changes', () => { + beforeEach(() => { + // Create a test setup with multiple locales and items + surveyTranslations.setSurveyCardContent(enLocale, mockSurveyCardContent); + surveyTranslations.setSurveyCardContent(deLocale, mockSurveyCardContent); + + // Set up translations for multiple items + const itemTranslations1 = new SurveyItemTranslations(); + itemTranslations1.setContent(enLocale, 'title', mockContent); + itemTranslations1.setContent(enLocale, 'description', mockCQMContent); + itemTranslations1.setContent(deLocale, 'title', mockCQMContent); + itemTranslations1.setContent(deLocale, 'description', mockContent); + + const itemTranslations2 = new SurveyItemTranslations(); + itemTranslations2.setContent(enLocale, 'label', mockContent); + itemTranslations2.setContent(deLocale, 'label', mockCQMContent); + + surveyTranslations.setItemTranslations('oldItemKey', itemTranslations1); + surveyTranslations.setItemTranslations('otherItem', itemTranslations2); + }); + + test('should rename item key and preserve translations', () => { + // Get original translations before rename + const originalTranslations = surveyTranslations.getItemTranslations('oldItemKey'); + const originalEnContent = originalTranslations!.getAllForLocale(enLocale); + const originalDeContent = originalTranslations!.getAllForLocale(deLocale); + + // Perform the rename + surveyTranslations.onItemKeyChanged('oldItemKey', 'newItemKey'); + + // Verify old key is removed + const result = surveyTranslations.toJson(); + expect(result!.en['oldItemKey']).toBeUndefined(); + expect(result!.de['oldItemKey']).toBeUndefined(); + + // Verify new key has the same translations + expect(result!.en['newItemKey']).toBeDefined(); + expect(result!.de['newItemKey']).toBeDefined(); + expect(result!.en['newItemKey']).toEqual(originalEnContent); + expect(result!.de['newItemKey']).toEqual(originalDeContent); + + // Verify content matches exactly + const newTranslations = surveyTranslations.getItemTranslations('newItemKey'); + expect(newTranslations!.getContent(enLocale, 'title')).toEqual(mockContent); + expect(newTranslations!.getContent(enLocale, 'description')).toEqual(mockCQMContent); + expect(newTranslations!.getContent(deLocale, 'title')).toEqual(mockCQMContent); + expect(newTranslations!.getContent(deLocale, 'description')).toEqual(mockContent); + }); + + test('should not affect other item translations when renaming', () => { + // Get original translations for other item + const otherItemOriginal = surveyTranslations.getItemTranslations('otherItem'); + const otherItemEnContent = otherItemOriginal!.getAllForLocale(enLocale); + const otherItemDeContent = otherItemOriginal!.getAllForLocale(deLocale); + + // Perform the rename + surveyTranslations.onItemKeyChanged('oldItemKey', 'newItemKey'); + + // Verify other item is unchanged + const result = surveyTranslations.toJson(); + expect(result!.en['otherItem']).toEqual(otherItemEnContent); + expect(result!.de['otherItem']).toEqual(otherItemDeContent); + + const otherItemAfter = surveyTranslations.getItemTranslations('otherItem'); + expect(otherItemAfter!.getContent(enLocale, 'label')).toEqual(mockContent); + expect(otherItemAfter!.getContent(deLocale, 'label')).toEqual(mockCQMContent); + }); + + test('should handle renaming non-existent item key gracefully', () => { + // Get state before attempted rename + const beforeRename = surveyTranslations.toJson(); + + // Attempt to rename non-existent key + surveyTranslations.onItemKeyChanged('nonExistentKey', 'someNewKey'); + + // Verify no changes occurred + const afterRename = surveyTranslations.toJson(); + expect(afterRename).toEqual(beforeRename); + + // Verify the new key was not created + expect(afterRename!.en['someNewKey']).toBeUndefined(); + expect(afterRename!.de['someNewKey']).toBeUndefined(); + + // Verify existing translations are still intact + expect(afterRename!.en['oldItemKey']).toBeDefined(); + expect(afterRename!.en['otherItem']).toBeDefined(); + }); + + test('should handle renaming to existing key by overwriting', () => { + // Perform rename where new key already exists + surveyTranslations.onItemKeyChanged('oldItemKey', 'otherItem'); + + const result = surveyTranslations.toJson(); + + // Verify old key is removed + expect(result!.en['oldItemKey']).toBeUndefined(); + expect(result!.de['oldItemKey']).toBeUndefined(); + + // Verify the existing key is overwritten with old key's content + expect(result!.en['otherItem']).toBeDefined(); + expect(result!.de['otherItem']).toBeDefined(); + + // The content should now be from the original 'oldItemKey' + const finalTranslations = surveyTranslations.getItemTranslations('otherItem'); + expect(finalTranslations!.getContent(enLocale, 'title')).toEqual(mockContent); + expect(finalTranslations!.getContent(enLocale, 'description')).toEqual(mockCQMContent); + expect(finalTranslations!.getContent(deLocale, 'title')).toEqual(mockCQMContent); + expect(finalTranslations!.getContent(deLocale, 'description')).toEqual(mockContent); + + // The original 'otherItem' content (label) should be gone + expect(finalTranslations!.getContent(enLocale, 'label')).toBeUndefined(); + }); + + test('should work with single locale', () => { + // Create a survey with only one locale + const singleLocaleSurvey = new SurveyTranslations(); + singleLocaleSurvey.setSurveyCardContent(enLocale, mockSurveyCardContent); + + const itemTranslations = new SurveyItemTranslations(); + itemTranslations.setContent(enLocale, 'title', mockContent); + itemTranslations.setContent(enLocale, 'description', mockCQMContent); + + singleLocaleSurvey.setItemTranslations('originalKey', itemTranslations); + + // Perform rename + singleLocaleSurvey.onItemKeyChanged('originalKey', 'renamedKey'); + + const result = singleLocaleSurvey.toJson(); + + // Verify old key is removed and new key has correct content + expect(result!.en['originalKey']).toBeUndefined(); + expect(result!.en['renamedKey']).toBeDefined(); + expect(result!.en['renamedKey']).toEqual({ + title: mockContent, + description: mockCQMContent + }); + }); + + test('should handle complex nested keys', () => { + // Set up translations with complex nested structure + const complexTranslations = new SurveyItemTranslations(); + complexTranslations.setContent(enLocale, 'comp1.title', mockContent); + complexTranslations.setContent(enLocale, 'comp1.description', mockCQMContent); + complexTranslations.setContent(enLocale, 'comp2.nested.label', mockContent); + complexTranslations.setContent(deLocale, 'comp1.title', mockCQMContent); + + surveyTranslations.setItemTranslations('survey.page1.group.item', complexTranslations); + + // Rename the complex key + surveyTranslations.onItemKeyChanged('survey.page1.group.item', 'survey.page2.newGroup.renamedItem'); + + const result = surveyTranslations.toJson(); + + // Verify old key is completely removed + expect(result!.en['survey.page1.group.item']).toBeUndefined(); + expect(result!.de['survey.page1.group.item']).toBeUndefined(); + + // Verify new key has all the complex nested content + expect(result!.en['survey.page2.newGroup.renamedItem']).toBeDefined(); + expect(result!.de['survey.page2.newGroup.renamedItem']).toBeDefined(); + + const renamedTranslations = surveyTranslations.getItemTranslations('survey.page2.newGroup.renamedItem'); + expect(renamedTranslations!.getContent(enLocale, 'comp1.title')).toEqual(mockContent); + expect(renamedTranslations!.getContent(enLocale, 'comp1.description')).toEqual(mockCQMContent); + expect(renamedTranslations!.getContent(enLocale, 'comp2.nested.label')).toEqual(mockContent); + expect(renamedTranslations!.getContent(deLocale, 'comp1.title')).toEqual(mockCQMContent); + }); + }); + + describe('component key changes', () => { + beforeEach(() => { + // Create a test setup with multiple locales and component translations + surveyTranslations.setSurveyCardContent(enLocale, mockSurveyCardContent); + surveyTranslations.setSurveyCardContent(deLocale, mockSurveyCardContent); + + // Set up translations for an item with multiple components + const itemTranslations = new SurveyItemTranslations(); + itemTranslations.setContent(enLocale, 'oldComponentKey', mockContent); + itemTranslations.setContent(enLocale, 'anotherComponent', mockCQMContent); + itemTranslations.setContent(enLocale, 'thirdComponent.title', mockContent); + itemTranslations.setContent(deLocale, 'oldComponentKey', mockCQMContent); + itemTranslations.setContent(deLocale, 'anotherComponent', mockContent); + itemTranslations.setContent(deLocale, 'thirdComponent.title', mockCQMContent); + + // Set up another item to ensure it's not affected + const otherItemTranslations = new SurveyItemTranslations(); + otherItemTranslations.setContent(enLocale, 'oldComponentKey', mockContent); + otherItemTranslations.setContent(deLocale, 'oldComponentKey', mockCQMContent); + + surveyTranslations.setItemTranslations('testItem', itemTranslations); + surveyTranslations.setItemTranslations('otherItem', otherItemTranslations); + }); + + test('should rename component key and preserve translations', () => { + // Get original content before rename + const originalTranslations = surveyTranslations.getItemTranslations('testItem'); + const originalEnContent = originalTranslations!.getContent(enLocale, 'oldComponentKey'); + const originalDeContent = originalTranslations!.getContent(deLocale, 'oldComponentKey'); + + // Perform the component rename + surveyTranslations.onComponentKeyChanged('testItem', 'oldComponentKey', 'newComponentKey'); + + // Verify old component key is removed + const updatedTranslations = surveyTranslations.getItemTranslations('testItem'); + expect(updatedTranslations!.getContent(enLocale, 'oldComponentKey')).toBeUndefined(); + expect(updatedTranslations!.getContent(deLocale, 'oldComponentKey')).toBeUndefined(); + + // Verify new component key has the same content + expect(updatedTranslations!.getContent(enLocale, 'newComponentKey')).toEqual(originalEnContent); + expect(updatedTranslations!.getContent(deLocale, 'newComponentKey')).toEqual(originalDeContent); + + // Verify the content matches exactly + expect(updatedTranslations!.getContent(enLocale, 'newComponentKey')).toEqual(mockContent); + expect(updatedTranslations!.getContent(deLocale, 'newComponentKey')).toEqual(mockCQMContent); + }); + + test('should not affect other components in the same item', () => { + // Get original content for other components + const originalTranslations = surveyTranslations.getItemTranslations('testItem'); + const anotherCompEnContent = originalTranslations!.getContent(enLocale, 'anotherComponent'); + const anotherCompDeContent = originalTranslations!.getContent(deLocale, 'anotherComponent'); + const thirdCompEnContent = originalTranslations!.getContent(enLocale, 'thirdComponent.title'); + const thirdCompDeContent = originalTranslations!.getContent(deLocale, 'thirdComponent.title'); + + // Perform the rename + surveyTranslations.onComponentKeyChanged('testItem', 'oldComponentKey', 'newComponentKey'); + + // Verify other components are unchanged + const updatedTranslations = surveyTranslations.getItemTranslations('testItem'); + expect(updatedTranslations!.getContent(enLocale, 'anotherComponent')).toEqual(anotherCompEnContent); + expect(updatedTranslations!.getContent(deLocale, 'anotherComponent')).toEqual(anotherCompDeContent); + expect(updatedTranslations!.getContent(enLocale, 'thirdComponent.title')).toEqual(thirdCompEnContent); + expect(updatedTranslations!.getContent(deLocale, 'thirdComponent.title')).toEqual(thirdCompDeContent); + }); + + test('should not affect other items', () => { + // Get original content for other item + const otherItemOriginal = surveyTranslations.getItemTranslations('otherItem'); + const otherItemEnContent = otherItemOriginal!.getContent(enLocale, 'oldComponentKey'); + const otherItemDeContent = otherItemOriginal!.getContent(deLocale, 'oldComponentKey'); + + // Perform the rename on the first item + surveyTranslations.onComponentKeyChanged('testItem', 'oldComponentKey', 'newComponentKey'); + + // Verify other item's component is unchanged + const otherItemAfter = surveyTranslations.getItemTranslations('otherItem'); + expect(otherItemAfter!.getContent(enLocale, 'oldComponentKey')).toEqual(otherItemEnContent); + expect(otherItemAfter!.getContent(deLocale, 'oldComponentKey')).toEqual(otherItemDeContent); + + // Verify the new key wasn't created in the other item + expect(otherItemAfter!.getContent(enLocale, 'newComponentKey')).toBeUndefined(); + expect(otherItemAfter!.getContent(deLocale, 'newComponentKey')).toBeUndefined(); + }); + + test('should handle renaming non-existent component key gracefully', () => { + // Get state before attempted rename + const beforeRename = surveyTranslations.toJson(); + + // Attempt to rename non-existent component key + surveyTranslations.onComponentKeyChanged('testItem', 'nonExistentComponent', 'someNewComponent'); + + // Verify no changes occurred + const afterRename = surveyTranslations.toJson(); + expect(afterRename).toEqual(beforeRename); + + // Verify the new key was not created + const updatedTranslations = surveyTranslations.getItemTranslations('testItem'); + expect(updatedTranslations!.getContent(enLocale, 'someNewComponent')).toBeUndefined(); + expect(updatedTranslations!.getContent(deLocale, 'someNewComponent')).toBeUndefined(); + }); + + test('should handle renaming component in non-existent item gracefully', () => { + // Get state before attempted rename + const beforeRename = surveyTranslations.toJson(); + + // Attempt to rename component in non-existent item + surveyTranslations.onComponentKeyChanged('nonExistentItem', 'oldComponentKey', 'newComponentKey'); + + // Verify no changes occurred + const afterRename = surveyTranslations.toJson(); + expect(afterRename).toEqual(beforeRename); + }); + + test('should handle renaming to existing component key by overwriting', () => { + // Perform rename where new key already exists + surveyTranslations.onComponentKeyChanged('testItem', 'oldComponentKey', 'anotherComponent'); + + const updatedTranslations = surveyTranslations.getItemTranslations('testItem'); + + // Verify old key is removed + expect(updatedTranslations!.getContent(enLocale, 'oldComponentKey')).toBeUndefined(); + expect(updatedTranslations!.getContent(deLocale, 'oldComponentKey')).toBeUndefined(); + + // Verify the existing key is overwritten with old key's content + expect(updatedTranslations!.getContent(enLocale, 'anotherComponent')).toEqual(mockContent); + expect(updatedTranslations!.getContent(deLocale, 'anotherComponent')).toEqual(mockCQMContent); + }); + + test('should work with single locale', () => { + // Create a survey with only one locale + const singleLocaleSurvey = new SurveyTranslations(); + singleLocaleSurvey.setSurveyCardContent(enLocale, mockSurveyCardContent); + + const itemTranslations = new SurveyItemTranslations(); + itemTranslations.setContent(enLocale, 'originalCompKey', mockContent); + itemTranslations.setContent(enLocale, 'otherComp', mockCQMContent); + + singleLocaleSurvey.setItemTranslations('testItem', itemTranslations); + + // Perform rename + singleLocaleSurvey.onComponentKeyChanged('testItem', 'originalCompKey', 'renamedCompKey'); + + const result = singleLocaleSurvey.getItemTranslations('testItem'); + + // Verify old key is removed and new key has correct content + expect(result!.getContent(enLocale, 'originalCompKey')).toBeUndefined(); + expect(result!.getContent(enLocale, 'renamedCompKey')).toEqual(mockContent); + expect(result!.getContent(enLocale, 'otherComp')).toEqual(mockCQMContent); + }); + + test('should handle complex nested component keys', () => { + // Set up translations with complex nested component structure + const complexTranslations = new SurveyItemTranslations(); + complexTranslations.setContent(enLocale, 'comp.nested.title', mockContent); + complexTranslations.setContent(enLocale, 'comp.nested.description', mockCQMContent); + complexTranslations.setContent(enLocale, 'otherComp.title', mockContent); + complexTranslations.setContent(deLocale, 'comp.nested.title', mockCQMContent); + + surveyTranslations.setItemTranslations('complexItem', complexTranslations); + + // Rename the nested component key + surveyTranslations.onComponentKeyChanged('complexItem', 'comp.nested.title', 'comp.renamed.title'); + + const renamedTranslations = surveyTranslations.getItemTranslations('complexItem'); + + // Verify old key is removed + expect(renamedTranslations!.getContent(enLocale, 'comp.nested.title')).toBeUndefined(); + expect(renamedTranslations!.getContent(deLocale, 'comp.nested.title')).toBeUndefined(); + + // Verify new key has the correct content + expect(renamedTranslations!.getContent(enLocale, 'comp.renamed.title')).toEqual(mockContent); + expect(renamedTranslations!.getContent(deLocale, 'comp.renamed.title')).toEqual(mockCQMContent); + + // Verify other components are unchanged + expect(renamedTranslations!.getContent(enLocale, 'comp.nested.description')).toEqual(mockCQMContent); + expect(renamedTranslations!.getContent(enLocale, 'otherComp.title')).toEqual(mockContent); + }); + + test('should handle multiple sequential renames', () => { + // Perform multiple renames in sequence + surveyTranslations.onComponentKeyChanged('testItem', 'oldComponentKey', 'intermediateKey'); + surveyTranslations.onComponentKeyChanged('testItem', 'intermediateKey', 'finalKey'); + + const finalTranslations = surveyTranslations.getItemTranslations('testItem'); + + // Verify all intermediate keys are removed + expect(finalTranslations!.getContent(enLocale, 'oldComponentKey')).toBeUndefined(); + expect(finalTranslations!.getContent(enLocale, 'intermediateKey')).toBeUndefined(); + expect(finalTranslations!.getContent(deLocale, 'oldComponentKey')).toBeUndefined(); + expect(finalTranslations!.getContent(deLocale, 'intermediateKey')).toBeUndefined(); + + // Verify final key has the original content + expect(finalTranslations!.getContent(enLocale, 'finalKey')).toEqual(mockContent); + expect(finalTranslations!.getContent(deLocale, 'finalKey')).toEqual(mockCQMContent); + + // Verify other components are still intact + expect(finalTranslations!.getContent(enLocale, 'anotherComponent')).toEqual(mockCQMContent); + expect(finalTranslations!.getContent(enLocale, 'thirdComponent.title')).toEqual(mockContent); + }); + }); + + describe('edge cases', () => { + test('should not allow empty strings as locale keys for survey card content', () => { + expect(() => { + surveyTranslations.setSurveyCardContent('', mockSurveyCardContent); + }).toThrow('Locale cannot be empty'); + + expect(() => { + surveyTranslations.removeLocale(''); + }).toThrow('Locale cannot be empty'); + + expect(() => { + surveyTranslations.renameLocale('', 'new'); + }).toThrow('Locale cannot be empty'); + + expect(() => { + surveyTranslations.renameLocale(enLocale, ''); + }).toThrow('Locale cannot be empty'); + + expect(() => { + surveyTranslations.cloneLocaleAs('', 'new'); + }).toThrow('Locale cannot be empty'); + + expect(() => { + surveyTranslations.cloneLocaleAs(enLocale, ''); + }).toThrow('Locale cannot be empty'); + }); + + test('should not allow empty strings as locale keys for item translations', () => { + const itemTranslations = new SurveyItemTranslations(); + + expect(() => { + itemTranslations.setContent('', 'title', mockContent); + }).toThrow('Locale cannot be empty'); + + // The item translations should be empty since no valid locale was set + expect(itemTranslations.locales).toEqual([]); + }); + + test('should not allow whitespace-only strings as locale keys', () => { + expect(() => { + surveyTranslations.setSurveyCardContent(' ', mockSurveyCardContent); + }).toThrow('Locale cannot be empty'); + + const itemTranslations = new SurveyItemTranslations(); + expect(() => { + itemTranslations.setContent(' \t\n ', 'title', mockContent); + }).toThrow('Locale cannot be empty'); + }); + + test('should handle multiple operations on same locale', () => { + surveyTranslations.setSurveyCardContent(enLocale, mockSurveyCardContent); + + const itemTranslations = new SurveyItemTranslations(); + itemTranslations.setContent(enLocale, 'title', mockContent); + + surveyTranslations.setItemTranslations('item1', itemTranslations); + + const result = surveyTranslations.toJson(); + expect(result!.en.surveyCardContent).toEqual(mockSurveyCardContent); + expect(result!.en.item1).toEqual({ title: mockContent }); + }); + }); +}); diff --git a/src/__tests__/undo-redo.test.ts b/src/__tests__/undo-redo.test.ts new file mode 100644 index 0000000..d2181a3 --- /dev/null +++ b/src/__tests__/undo-redo.test.ts @@ -0,0 +1,1121 @@ +import { SurveyEditorUndoRedo } from '../survey-editor/undo-redo'; +import { JsonSurvey, CURRENT_SURVEY_SCHEMA } from '../survey/survey-file-schema'; +import { GroupItem, SurveyItemType } from '../survey/items'; + +// Helper function to create a minimal valid JsonSurvey +const createSurvey = (id: string = 'survey', title: string = 'Test Survey'): JsonSurvey => ({ + $schema: CURRENT_SURVEY_SCHEMA, + surveyItems: { + [id]: { + itemType: SurveyItemType.Group, + items: [`${id}.question1`] + }, + [`${id}.question1`]: { + itemType: SurveyItemType.Display, + components: [ + { + key: 'title', + type: 'text', + properties: { + content: title + } + } + ] + } + } +}); + +// Helper function to create a large survey for memory testing +const createLargeSurvey = (itemCount: number): JsonSurvey => { + const survey: JsonSurvey = { + $schema: CURRENT_SURVEY_SCHEMA, + surveyItems: { + survey: { + itemType: SurveyItemType.Group, + items: [] + } + } + }; + + const rootGroup = survey.surveyItems.survey as GroupItem; + + for (let i = 0; i < itemCount; i++) { + const itemKey = `survey.item${i}`; + rootGroup.items?.push(itemKey); + + survey.surveyItems[itemKey] = { + itemType: SurveyItemType.Display, + components: [ + { + key: 'content', + type: 'text', + properties: { + content: `Content for item ${i}`.repeat(100) // Make it larger + } + } + ] + }; + } + + return survey; +}; + +describe('SurveyEditorUndoRedo', () => { + let undoRedo: SurveyEditorUndoRedo; + let initialSurvey: JsonSurvey; + + beforeEach(() => { + initialSurvey = createSurvey(); + undoRedo = new SurveyEditorUndoRedo(initialSurvey); + }); + + describe('Initialization', () => { + test('should initialize with initial survey', () => { + expect(undoRedo.getCurrentState()).toEqual(initialSurvey); + expect(undoRedo.canUndo()).toBe(false); + expect(undoRedo.canRedo()).toBe(false); + }); + + test('should accept custom configuration', () => { + const config = { + maxTotalMemoryMB: 100, + minHistorySize: 5, + maxHistorySize: 500 + }; + + const customUndoRedo = new SurveyEditorUndoRedo(initialSurvey, config); + expect(customUndoRedo.getConfig()).toEqual(config); + }); + + test('should use default configuration when not provided', () => { + const config = undoRedo.getConfig(); + expect(config.maxTotalMemoryMB).toBe(50); + expect(config.minHistorySize).toBe(10); + expect(config.maxHistorySize).toBe(200); + }); + }); + + describe('Commit functionality', () => { + test('should commit new state and enable undo', () => { + const newSurvey = createSurvey('survey2', 'Modified Survey'); + + undoRedo.commit(newSurvey, 'Added new survey'); + + expect(undoRedo.getCurrentState()).toEqual(newSurvey); + expect(undoRedo.canUndo()).toBe(true); + expect(undoRedo.canRedo()).toBe(false); + }); + + test('should clear redo history when committing after undo', () => { + const survey1 = createSurvey('survey1', 'Survey 1'); + const survey2 = createSurvey('survey2', 'Survey 2'); + const survey3 = createSurvey('survey3', 'Survey 3'); + + undoRedo.commit(survey1, 'Step 1'); + undoRedo.commit(survey2, 'Step 2'); + + // Undo once + undoRedo.undo(); + expect(undoRedo.canRedo()).toBe(true); + + // Commit new change, should clear redo history + undoRedo.commit(survey3, 'Step 3'); + expect(undoRedo.canRedo()).toBe(false); + expect(undoRedo.getCurrentState()).toEqual(survey3); + }); + + test('should create deep clones of survey data', () => { + const survey = createSurvey(); + undoRedo.commit(survey, 'Test commit'); + + // Modify original survey + survey.surveyItems.survey = { + itemType: SurveyItemType.Display, + components: [] + }; + + // Stored state should be unchanged + const storedState = undoRedo.getCurrentState(); + expect(storedState.surveyItems.survey.itemType).toBe(SurveyItemType.Group); + }); + }); + + describe('Undo functionality', () => { + test('should undo to previous state', () => { + const survey1 = createSurvey('survey1', 'Survey 1'); + + undoRedo.commit(survey1, 'Step 1'); + + const undoResult = undoRedo.undo(); + expect(undoResult).toEqual(initialSurvey); + expect(undoRedo.getCurrentState()).toEqual(initialSurvey); + expect(undoRedo.canUndo()).toBe(false); + expect(undoRedo.canRedo()).toBe(true); + }); + + test('should return null when no undo available', () => { + expect(undoRedo.undo()).toBeNull(); + }); + + test('should handle multiple undos', () => { + const survey1 = createSurvey('survey1', 'Survey 1'); + const survey2 = createSurvey('survey2', 'Survey 2'); + + undoRedo.commit(survey1, 'Step 1'); + undoRedo.commit(survey2, 'Step 2'); + + // First undo + expect(undoRedo.undo()).toEqual(survey1); + expect(undoRedo.canUndo()).toBe(true); + + // Second undo + expect(undoRedo.undo()).toEqual(initialSurvey); + expect(undoRedo.canUndo()).toBe(false); + }); + }); + + describe('Redo functionality', () => { + test('should redo to next state', () => { + const survey1 = createSurvey('survey1', 'Survey 1'); + + undoRedo.commit(survey1, 'Step 1'); + undoRedo.undo(); + + const redoResult = undoRedo.redo(); + expect(redoResult).toEqual(survey1); + expect(undoRedo.getCurrentState()).toEqual(survey1); + expect(undoRedo.canUndo()).toBe(true); + expect(undoRedo.canRedo()).toBe(false); + }); + + test('should return null when no redo available', () => { + expect(undoRedo.redo()).toBeNull(); + }); + + test('should handle multiple redos', () => { + const survey1 = createSurvey('survey1', 'Survey 1'); + const survey2 = createSurvey('survey2', 'Survey 2'); + + undoRedo.commit(survey1, 'Step 1'); + undoRedo.commit(survey2, 'Step 2'); + + // Undo twice + undoRedo.undo(); + undoRedo.undo(); + + // First redo + expect(undoRedo.redo()).toEqual(survey1); + expect(undoRedo.canRedo()).toBe(true); + + // Second redo + expect(undoRedo.redo()).toEqual(survey2); + expect(undoRedo.canRedo()).toBe(false); + }); + }); + + describe('State checking', () => { + test('canUndo should work correctly', () => { + expect(undoRedo.canUndo()).toBe(false); + + undoRedo.commit(createSurvey('test'), 'Test commit'); + expect(undoRedo.canUndo()).toBe(true); + + undoRedo.undo(); + expect(undoRedo.canUndo()).toBe(false); + }); + + test('canRedo should work correctly', () => { + expect(undoRedo.canRedo()).toBe(false); + + undoRedo.commit(createSurvey('test'), 'Test commit'); + expect(undoRedo.canRedo()).toBe(false); + + undoRedo.undo(); + expect(undoRedo.canRedo()).toBe(true); + + undoRedo.redo(); + expect(undoRedo.canRedo()).toBe(false); + }); + }); + + describe('Description functionality', () => { + test('should return correct undo description', () => { + undoRedo.commit(createSurvey('test'), 'Test operation'); + + expect(undoRedo.getUndoDescription()).toBe('Test operation'); + + undoRedo.undo(); + expect(undoRedo.getUndoDescription()).toBeNull(); + }); + + test('should return correct redo description', () => { + undoRedo.commit(createSurvey('test'), 'Test operation'); + + expect(undoRedo.getRedoDescription()).toBeNull(); + + undoRedo.undo(); + expect(undoRedo.getRedoDescription()).toBe('Test operation'); + + undoRedo.redo(); + expect(undoRedo.getRedoDescription()).toBeNull(); + }); + + test('should handle multiple operations with descriptions', () => { + undoRedo.commit(createSurvey('test1'), 'Operation 1'); + undoRedo.commit(createSurvey('test2'), 'Operation 2'); + + expect(undoRedo.getUndoDescription()).toBe('Operation 2'); + expect(undoRedo.getRedoDescription()).toBeNull(); + + undoRedo.undo(); + expect(undoRedo.getUndoDescription()).toBe('Operation 1'); + expect(undoRedo.getRedoDescription()).toBe('Operation 2'); + + undoRedo.undo(); + expect(undoRedo.getUndoDescription()).toBeNull(); + expect(undoRedo.getRedoDescription()).toBe('Operation 1'); + }); + }); + + describe('Memory management', () => { + test('should track memory usage', () => { + const usage = undoRedo.getMemoryUsage(); + expect(usage.entries).toBe(1); // Initial state + expect(usage.totalMB).toBeGreaterThan(0); + + undoRedo.commit(createSurvey('test'), 'Test commit'); + + const newUsage = undoRedo.getMemoryUsage(); + expect(newUsage.entries).toBe(2); + expect(newUsage.totalMB).toBeGreaterThan(usage.totalMB); + }); + + test('should cleanup old history when memory limit exceeded', () => { + const smallMemoryUndoRedo = new SurveyEditorUndoRedo(initialSurvey, { + maxTotalMemoryMB: 0.001, // Very small limit + minHistorySize: 2, + maxHistorySize: 10 + }); + + const largeSurvey = createLargeSurvey(10); + + // Add several large surveys + for (let i = 0; i < 5; i++) { + smallMemoryUndoRedo.commit(largeSurvey, `Large survey ${i}`); + } + + const usage = smallMemoryUndoRedo.getMemoryUsage(); + expect(usage.entries).toBeGreaterThanOrEqual(2); // Should maintain minimum + expect(usage.entries).toBeLessThan(6); // Should have cleaned up some + }); + + test('should cleanup old history when max history size exceeded', () => { + const maxHistoryUndoRedo = new SurveyEditorUndoRedo(initialSurvey, { + maxTotalMemoryMB: 1000, // Very large memory limit + minHistorySize: 2, + maxHistorySize: 3 + }); + + // Add more than max history size + for (let i = 0; i < 5; i++) { + maxHistoryUndoRedo.commit(createSurvey(`test${i}`), `Operation ${i}`); + } + + const usage = maxHistoryUndoRedo.getMemoryUsage(); + expect(usage.entries).toBe(3); // Should be limited to maxHistorySize + }); + + test('should preserve minimum history size', () => { + const minHistoryUndoRedo = new SurveyEditorUndoRedo(initialSurvey, { + maxTotalMemoryMB: 0.0001, // Extremely small limit + minHistorySize: 5, + maxHistorySize: 10 + }); + + const largeSurvey = createLargeSurvey(20); + + // Add several large surveys + for (let i = 0; i < 8; i++) { + minHistoryUndoRedo.commit(largeSurvey, `Large survey ${i}`); + } + + const usage = minHistoryUndoRedo.getMemoryUsage(); + expect(usage.entries).toBeGreaterThanOrEqual(5); // Should maintain minimum + }); + }); + + describe('Error handling', () => { + test('should throw error for invalid history state', () => { + // Force invalid state by manipulating internal state + const invalidUndoRedo = new SurveyEditorUndoRedo(initialSurvey); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (invalidUndoRedo as any).currentIndex = -1; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (invalidUndoRedo as any).history = []; + + expect(() => invalidUndoRedo.getCurrentState()).toThrow('Invalid history state'); + }); + + test('should handle getCurrentState with valid indices', () => { + undoRedo.commit(createSurvey('test'), 'Test'); + expect(() => undoRedo.getCurrentState()).not.toThrow(); + }); + }); + + describe('Integration scenarios', () => { + test('should handle complex undo/redo sequence', () => { + const survey1 = createSurvey('survey1', 'Survey 1'); + const survey2 = createSurvey('survey2', 'Survey 2'); + const survey3 = createSurvey('survey3', 'Survey 3'); + const survey4 = createSurvey('survey4', 'Survey 4'); + + // Build history + undoRedo.commit(survey1, 'Step 1'); + undoRedo.commit(survey2, 'Step 2'); + undoRedo.commit(survey3, 'Step 3'); + + // Undo twice + undoRedo.undo(); + undoRedo.undo(); + expect(undoRedo.getCurrentState()).toEqual(survey1); + + // Redo once + undoRedo.redo(); + expect(undoRedo.getCurrentState()).toEqual(survey2); + + // Commit new change (should clear remaining redo history) + undoRedo.commit(survey4, 'Step 4'); + expect(undoRedo.getCurrentState()).toEqual(survey4); + expect(undoRedo.canRedo()).toBe(false); + + // Verify undo path + expect(undoRedo.undo()).toEqual(survey2); + expect(undoRedo.undo()).toEqual(survey1); + expect(undoRedo.undo()).toEqual(initialSurvey); + expect(undoRedo.canUndo()).toBe(false); + }); + + test('should maintain data integrity through multiple operations', () => { + const surveys: JsonSurvey[] = []; + + // Create 10 different surveys + for (let i = 0; i < 10; i++) { + surveys.push(createSurvey(`survey${i}`, `Survey ${i}`)); + } + + // Commit all surveys + surveys.forEach((survey, index) => { + undoRedo.commit(survey, `Operation ${index}`); + }); + + // Undo all changes + for (let i = surveys.length - 1; i >= 0; i--) { + const expected = i === 0 ? initialSurvey : surveys[i - 1]; + expect(undoRedo.undo()).toEqual(expected); + } + + // Redo all changes + surveys.forEach((survey) => { + expect(undoRedo.redo()).toEqual(survey); + }); + + // Verify final state + expect(undoRedo.getCurrentState()).toEqual(surveys[surveys.length - 1]); + }); + + test('should handle rapid commits and undos', () => { + const operations = 50; + + // Rapid commits + for (let i = 0; i < operations; i++) { + undoRedo.commit(createSurvey(`rapid${i}`), `Rapid ${i}`); + } + + expect(undoRedo.getMemoryUsage().entries).toBeGreaterThan(0); + expect(undoRedo.canUndo()).toBe(true); + + // Rapid undos + let undoCount = 0; + while (undoRedo.canUndo()) { + undoRedo.undo(); + undoCount++; + } + + expect(undoCount).toBeGreaterThan(0); + expect(undoRedo.getCurrentState()).toEqual(initialSurvey); + }); + }); + + describe('Configuration edge cases', () => { + test('should handle extreme configuration values', () => { + const extremeConfig = { + maxTotalMemoryMB: 0, + minHistorySize: 0, + maxHistorySize: 1 + }; + + const extremeUndoRedo = new SurveyEditorUndoRedo(initialSurvey, extremeConfig); + expect(() => extremeUndoRedo.commit(createSurvey('test'), 'Test')).not.toThrow(); + }); + + test('should handle partial configuration', () => { + const partialConfig = { + maxTotalMemoryMB: 25 + }; + + const partialUndoRedo = new SurveyEditorUndoRedo(initialSurvey, partialConfig); + const config = partialUndoRedo.getConfig(); + + expect(config.maxTotalMemoryMB).toBe(25); + expect(config.minHistorySize).toBe(10); // Default + expect(config.maxHistorySize).toBe(200); // Default + }); + }); +}); + +// New tests for enhanced undo/redo functionality +describe('Enhanced Undo/Redo Functionality', () => { + let undoRedo: SurveyEditorUndoRedo; + let initialSurvey: JsonSurvey; + let surveys: JsonSurvey[]; + + beforeEach(() => { + initialSurvey = createSurvey(); + undoRedo = new SurveyEditorUndoRedo(initialSurvey); + + // Create a set of test surveys + surveys = [ + createSurvey('survey1', 'Survey 1'), + createSurvey('survey2', 'Survey 2'), + createSurvey('survey3', 'Survey 3'), + createSurvey('survey4', 'Survey 4'), + ]; + + // Commit all surveys to build history + surveys.forEach((survey, index) => { + undoRedo.commit(survey, `Operation ${index + 1}`); + }); + }); + + describe('History Information', () => { + test('should return complete history list', () => { + const history = undoRedo.getHistory(); + + expect(history).toHaveLength(5); // initial + 4 surveys + expect(history[0].description).toBe('Initial state'); + expect(history[1].description).toBe('Operation 1'); + expect(history[2].description).toBe('Operation 2'); + expect(history[3].description).toBe('Operation 3'); + expect(history[4].description).toBe('Operation 4'); + + // Check that current index is marked correctly + expect(history[4].isCurrent).toBe(true); + expect(history[0].isCurrent).toBe(false); + expect(history[1].isCurrent).toBe(false); + expect(history[2].isCurrent).toBe(false); + expect(history[3].isCurrent).toBe(false); + + // Check that all entries have required properties + history.forEach((entry, index) => { + expect(entry.index).toBe(index); + expect(typeof entry.description).toBe('string'); + expect(typeof entry.timestamp).toBe('number'); + expect(typeof entry.memorySize).toBe('number'); + expect(typeof entry.isCurrent).toBe('boolean'); + expect(entry.timestamp).toBeGreaterThan(0); + expect(entry.memorySize).toBeGreaterThan(0); + }); + }); + + test('should track current index correctly', () => { + expect(undoRedo.getCurrentIndex()).toBe(4); + expect(undoRedo.getHistoryLength()).toBe(5); + + undoRedo.undo(); + expect(undoRedo.getCurrentIndex()).toBe(3); + expect(undoRedo.getHistoryLength()).toBe(5); + + undoRedo.undo(); + expect(undoRedo.getCurrentIndex()).toBe(2); + expect(undoRedo.getHistoryLength()).toBe(5); + + undoRedo.redo(); + expect(undoRedo.getCurrentIndex()).toBe(3); + expect(undoRedo.getHistoryLength()).toBe(5); + }); + + test('should update isCurrent flag when navigating', () => { + undoRedo.undo(); // Move to index 3 + + const history = undoRedo.getHistory(); + expect(history[3].isCurrent).toBe(true); + expect(history[4].isCurrent).toBe(false); + + undoRedo.redo(); // Move back to index 4 + + const updatedHistory = undoRedo.getHistory(); + expect(updatedHistory[4].isCurrent).toBe(true); + expect(updatedHistory[3].isCurrent).toBe(false); + }); + }); + + describe('Jump to Index', () => { + test('should jump forward to specific index', () => { + const result = undoRedo.jumpToIndex(2); + + expect(result).toEqual(surveys[1]); + expect(undoRedo.getCurrentIndex()).toBe(2); + expect(undoRedo.getCurrentState()).toEqual(surveys[1]); + }); + + test('should jump backward to specific index', () => { + undoRedo.jumpToIndex(1); + + const result = undoRedo.jumpToIndex(3); + + expect(result).toEqual(surveys[2]); + expect(undoRedo.getCurrentIndex()).toBe(3); + expect(undoRedo.getCurrentState()).toEqual(surveys[2]); + }); + + test('should handle jumping to initial state', () => { + const result = undoRedo.jumpToIndex(0); + + expect(result).toEqual(initialSurvey); + expect(undoRedo.getCurrentIndex()).toBe(0); + expect(undoRedo.getCurrentState()).toEqual(initialSurvey); + }); + + test('should return null for invalid indices', () => { + expect(undoRedo.jumpToIndex(4)).toBeNull(); // Current index + expect(undoRedo.jumpToIndex(5)).toBeNull(); // Beyond history + expect(undoRedo.jumpToIndex(-1)).toBeNull(); // Invalid index + }); + + test('should handle multiple jumps', () => { + // Jump to index 1 + undoRedo.jumpToIndex(1); + expect(undoRedo.getCurrentState()).toEqual(surveys[0]); + + // Jump to index 3 + undoRedo.jumpToIndex(3); + expect(undoRedo.getCurrentState()).toEqual(surveys[2]); + + // Jump to index 0 + undoRedo.jumpToIndex(0); + expect(undoRedo.getCurrentState()).toEqual(initialSurvey); + }); + + test('should validate canJumpToIndex correctly', () => { + expect(undoRedo.canJumpToIndex(0)).toBe(true); + expect(undoRedo.canJumpToIndex(1)).toBe(true); + expect(undoRedo.canJumpToIndex(2)).toBe(true); + expect(undoRedo.canJumpToIndex(3)).toBe(true); + expect(undoRedo.canJumpToIndex(4)).toBe(false); // Current index + expect(undoRedo.canJumpToIndex(5)).toBe(false); // Beyond history + expect(undoRedo.canJumpToIndex(-1)).toBe(false); // Invalid index + + // After jumping to index 2 + undoRedo.jumpToIndex(2); + expect(undoRedo.canJumpToIndex(0)).toBe(true); + expect(undoRedo.canJumpToIndex(1)).toBe(true); + expect(undoRedo.canJumpToIndex(2)).toBe(false); // Current index + expect(undoRedo.canJumpToIndex(3)).toBe(true); + expect(undoRedo.canJumpToIndex(4)).toBe(true); + }); + }); + + describe('History Navigation Integration', () => { + test('should maintain history integrity during navigation', () => { + const originalHistory = undoRedo.getHistory(); + + // Navigate around + undoRedo.jumpToIndex(1); + undoRedo.jumpToIndex(3); + undoRedo.jumpToIndex(0); + undoRedo.jumpToIndex(2); + + // History should remain the same + const currentHistory = undoRedo.getHistory(); + expect(currentHistory).toHaveLength(originalHistory.length); + + // Only the current flag should change + currentHistory.forEach((entry, index) => { + expect(entry.description).toBe(originalHistory[index].description); + expect(entry.timestamp).toBe(originalHistory[index].timestamp); + expect(entry.memorySize).toBe(originalHistory[index].memorySize); + expect(entry.index).toBe(originalHistory[index].index); + expect(entry.isCurrent).toBe(index === 2); // Current index is 2 + }); + }); + + test('should work correctly with normal undo/redo after navigation', () => { + undoRedo.jumpToIndex(2); + + // Normal undo should work + const undoResult = undoRedo.undo(); + expect(undoResult).toEqual(surveys[0]); + expect(undoRedo.getCurrentIndex()).toBe(1); + + // Normal redo should work + const redoResult = undoRedo.redo(); + expect(redoResult).toEqual(surveys[1]); + expect(undoRedo.getCurrentIndex()).toBe(2); + }); + + test('should handle navigation after committing new changes', () => { + undoRedo.jumpToIndex(2); + + // Commit new change (should clear future history) + const newSurvey = createSurvey('new-survey', 'New Survey'); + undoRedo.commit(newSurvey, 'New operation'); + + expect(undoRedo.getHistoryLength()).toBe(4); // Initial + 2 surveys + new survey + expect(undoRedo.getCurrentIndex()).toBe(3); + + // Can still navigate within new history + expect(undoRedo.canJumpToIndex(0)).toBe(true); + expect(undoRedo.canJumpToIndex(1)).toBe(true); + expect(undoRedo.canJumpToIndex(2)).toBe(true); + expect(undoRedo.canJumpToIndex(3)).toBe(false); // Current index + expect(undoRedo.canJumpToIndex(4)).toBe(false); // Beyond new history + }); + }); + + describe('Edge Cases and Error Handling', () => { + test('should handle empty history navigation', () => { + const emptyUndoRedo = new SurveyEditorUndoRedo(initialSurvey); + + expect(emptyUndoRedo.getHistoryLength()).toBe(1); + expect(emptyUndoRedo.getCurrentIndex()).toBe(0); + + expect(emptyUndoRedo.canJumpToIndex(0)).toBe(false); // Current index + expect(emptyUndoRedo.jumpToIndex(0)).toBeNull(); + }); + + test('should handle boundary conditions', () => { + // Test at the start of history + undoRedo.jumpToIndex(0); + + expect(undoRedo.canJumpToIndex(0)).toBe(false); // Current index + expect(undoRedo.jumpToIndex(0)).toBeNull(); + + // Test at the end of history + undoRedo.jumpToIndex(4); + + expect(undoRedo.canJumpToIndex(4)).toBe(false); // Current index + expect(undoRedo.jumpToIndex(4)).toBeNull(); + }); + + test('should handle navigation with large indices', () => { + expect(undoRedo.jumpToIndex(1000)).toBeNull(); + expect(undoRedo.canJumpToIndex(1000)).toBe(false); + }); + + test('should handle navigation with negative indices', () => { + expect(undoRedo.jumpToIndex(-1)).toBeNull(); + expect(undoRedo.canJumpToIndex(-1)).toBe(false); + }); + }); +}); + +// JSON Serialization and Deserialization Tests +describe('SurveyEditorUndoRedo JSON Serialization', () => { + let undoRedo: SurveyEditorUndoRedo; + let initialSurvey: JsonSurvey; + let survey1: JsonSurvey; + let survey2: JsonSurvey; + + beforeEach(() => { + initialSurvey = createSurvey(); + survey1 = createSurvey('survey1', 'Survey 1'); + survey2 = createSurvey('survey2', 'Survey 2'); + + undoRedo = new SurveyEditorUndoRedo(initialSurvey, { + maxTotalMemoryMB: 25, + minHistorySize: 5, + maxHistorySize: 100 + }); + + // Build some history + undoRedo.commit(survey1, 'Added survey 1'); + undoRedo.commit(survey2, 'Added survey 2'); + }); + + describe('toJSON method', () => { + test('should serialize complete state to JSON', () => { + const jsonData = undoRedo.toJSON(); + + expect(jsonData).toBeDefined(); + expect(jsonData.history).toBeDefined(); + expect(jsonData.currentIndex).toBeDefined(); + expect(jsonData.config).toBeDefined(); + }); + + test('should include all history entries', () => { + const jsonData = undoRedo.toJSON(); + + expect(jsonData.history).toHaveLength(3); // initial + 2 commits + expect(jsonData.history[0].description).toBe('Initial state'); + expect(jsonData.history[1].description).toBe('Added survey 1'); + expect(jsonData.history[2].description).toBe('Added survey 2'); + }); + + test('should include current index', () => { + const jsonData = undoRedo.toJSON(); + expect(jsonData.currentIndex).toBe(2); + + undoRedo.undo(); + const jsonDataAfterUndo = undoRedo.toJSON(); + expect(jsonDataAfterUndo.currentIndex).toBe(1); + }); + + test('should include configuration', () => { + const jsonData = undoRedo.toJSON(); + + expect(jsonData.config).toEqual({ + maxTotalMemoryMB: 25, + minHistorySize: 5, + maxHistorySize: 100 + }); + }); + + test('should include all history entry properties', () => { + const jsonData = undoRedo.toJSON(); + + jsonData.history.forEach((entry, index) => { + expect(entry.survey).toBeDefined(); + expect(typeof entry.timestamp).toBe('number'); + expect(typeof entry.description).toBe('string'); + expect(typeof entry.memorySize).toBe('number'); + expect(entry.timestamp).toBeGreaterThan(0); + expect(entry.memorySize).toBeGreaterThan(0); + }); + }); + + test('should create deep copies of survey data', () => { + const jsonData = undoRedo.toJSON(); + + // Modify the original survey + initialSurvey.surveyItems.survey = { + itemType: SurveyItemType.Display, + components: [] + }; + + // JSON data should be unchanged + expect(jsonData.history[0].survey.surveyItems.survey.itemType).toBe(SurveyItemType.Group); + }); + + test('should work with different history positions', () => { + undoRedo.undo(); // Move to position 1 + const jsonData1 = undoRedo.toJSON(); + expect(jsonData1.currentIndex).toBe(1); + + undoRedo.undo(); // Move to position 0 + const jsonData2 = undoRedo.toJSON(); + expect(jsonData2.currentIndex).toBe(0); + + undoRedo.redo(); // Move back to position 1 + const jsonData3 = undoRedo.toJSON(); + expect(jsonData3.currentIndex).toBe(1); + }); + }); + + describe('fromJSON method', () => { + test('should recreate instance from JSON data', () => { + const jsonData = undoRedo.toJSON(); + const restored = SurveyEditorUndoRedo.fromJSON(jsonData); + + expect(restored).toBeInstanceOf(SurveyEditorUndoRedo); + expect(restored.getCurrentIndex()).toBe(undoRedo.getCurrentIndex()); + expect(restored.getHistoryLength()).toBe(undoRedo.getHistoryLength()); + expect(restored.getCurrentState()).toEqual(undoRedo.getCurrentState()); + }); + + test('should restore configuration correctly', () => { + const jsonData = undoRedo.toJSON(); + const restored = SurveyEditorUndoRedo.fromJSON(jsonData); + + expect(restored.getConfig()).toEqual(undoRedo.getConfig()); + }); + + test('should restore complete history', () => { + const jsonData = undoRedo.toJSON(); + const restored = SurveyEditorUndoRedo.fromJSON(jsonData); + + const originalHistory = undoRedo.getHistory(); + const restoredHistory = restored.getHistory(); + + expect(restoredHistory).toHaveLength(originalHistory.length); + + restoredHistory.forEach((entry, index) => { + expect(entry.description).toBe(originalHistory[index].description); + expect(entry.timestamp).toBe(originalHistory[index].timestamp); + expect(entry.memorySize).toBe(originalHistory[index].memorySize); + expect(entry.isCurrent).toBe(originalHistory[index].isCurrent); + }); + }); + + test('should restore undo/redo functionality', () => { + const jsonData = undoRedo.toJSON(); + const restored = SurveyEditorUndoRedo.fromJSON(jsonData); + + expect(restored.canUndo()).toBe(undoRedo.canUndo()); + expect(restored.canRedo()).toBe(undoRedo.canRedo()); + expect(restored.getUndoDescription()).toBe(undoRedo.getUndoDescription()); + expect(restored.getRedoDescription()).toBe(undoRedo.getRedoDescription()); + }); + + test('should maintain data integrity after restoration', () => { + const jsonData = undoRedo.toJSON(); + const restored = SurveyEditorUndoRedo.fromJSON(jsonData); + + // Test undo functionality + const originalUndo = undoRedo.undo(); + const restoredUndo = restored.undo(); + expect(restoredUndo).toEqual(originalUndo); + + // Test redo functionality + const originalRedo = undoRedo.redo(); + const restoredRedo = restored.redo(); + expect(restoredRedo).toEqual(originalRedo); + }); + + test('should work with different history positions', () => { + undoRedo.undo(); // Move to position 1 + const jsonData = undoRedo.toJSON(); + const restored = SurveyEditorUndoRedo.fromJSON(jsonData); + + expect(restored.getCurrentIndex()).toBe(1); + expect(restored.getCurrentState()).toEqual(survey1); + expect(restored.canUndo()).toBe(true); + expect(restored.canRedo()).toBe(true); + }); + + test('should create independent instances', () => { + const jsonData = undoRedo.toJSON(); + const restored = SurveyEditorUndoRedo.fromJSON(jsonData); + + // Modify original + undoRedo.commit(createSurvey('new-survey'), 'New survey'); + + // Restored should be unaffected + expect(restored.getHistoryLength()).toBe(3); + expect(undoRedo.getHistoryLength()).toBe(4); + }); + + test('should handle memory usage correctly', () => { + const jsonData = undoRedo.toJSON(); + const restored = SurveyEditorUndoRedo.fromJSON(jsonData); + + const originalMemory = undoRedo.getMemoryUsage(); + const restoredMemory = restored.getMemoryUsage(); + + expect(restoredMemory.entries).toBe(originalMemory.entries); + expect(restoredMemory.totalMB).toBeCloseTo(originalMemory.totalMB, 2); + }); + }); + + describe('Error handling', () => { + test('should throw error for missing history', () => { + expect(() => { + SurveyEditorUndoRedo.fromJSON({ + history: [], + currentIndex: 0, + config: undoRedo.getConfig() + }); + }).toThrow('Invalid JSON data: history array is required and must not be empty'); + + expect(() => { + SurveyEditorUndoRedo.fromJSON({ + // @ts-expect-error Testing invalid input + history: null, + currentIndex: 0, + config: undoRedo.getConfig() + }); + }).toThrow('Invalid JSON data: history array is required and must not be empty'); + }); + + test('should throw error for invalid current index', () => { + const jsonData = undoRedo.toJSON(); + + expect(() => { + SurveyEditorUndoRedo.fromJSON({ + ...jsonData, + currentIndex: -1 + }); + }).toThrow('Invalid JSON data: currentIndex must be a valid index within the history array'); + + expect(() => { + SurveyEditorUndoRedo.fromJSON({ + ...jsonData, + currentIndex: jsonData.history.length + }); + }).toThrow('Invalid JSON data: currentIndex must be a valid index within the history array'); + + expect(() => { + SurveyEditorUndoRedo.fromJSON({ + ...jsonData, + // @ts-expect-error Testing invalid input + currentIndex: 'invalid' + }); + }).toThrow('Invalid JSON data: currentIndex must be a valid index within the history array'); + }); + + test('should throw error for missing config', () => { + const jsonData = undoRedo.toJSON(); + + expect(() => { + SurveyEditorUndoRedo.fromJSON({ + ...jsonData, + // @ts-expect-error Testing invalid input + config: null + }); + }).toThrow('Invalid JSON data: config is required'); + + expect(() => { + SurveyEditorUndoRedo.fromJSON({ + history: jsonData.history, + currentIndex: jsonData.currentIndex, + // @ts-expect-error Testing invalid input + config: undefined + }); + }).toThrow('Invalid JSON data: config is required'); + }); + }); + + describe('Round-trip serialization', () => { + test('should maintain identical state after round-trip', () => { + // Create a complex state + const survey3 = createSurvey('survey3', 'Survey 3'); + undoRedo.commit(survey3, 'Added survey 3'); + undoRedo.undo(); + undoRedo.undo(); + + // Round-trip serialization + const jsonData = undoRedo.toJSON(); + const restored = SurveyEditorUndoRedo.fromJSON(jsonData); + + // Verify complete state equality + expect(restored.getCurrentIndex()).toBe(undoRedo.getCurrentIndex()); + expect(restored.getHistoryLength()).toBe(undoRedo.getHistoryLength()); + expect(restored.getCurrentState()).toEqual(undoRedo.getCurrentState()); + expect(restored.canUndo()).toBe(undoRedo.canUndo()); + expect(restored.canRedo()).toBe(undoRedo.canRedo()); + expect(restored.getUndoDescription()).toBe(undoRedo.getUndoDescription()); + expect(restored.getRedoDescription()).toBe(undoRedo.getRedoDescription()); + expect(restored.getConfig()).toEqual(undoRedo.getConfig()); + + // Verify history matches + const originalHistory = undoRedo.getHistory(); + const restoredHistory = restored.getHistory(); + expect(restoredHistory).toEqual(originalHistory); + }); + + test('should work with JSON.stringify and JSON.parse', () => { + const jsonData = undoRedo.toJSON(); + const jsonString = JSON.stringify(jsonData); + const parsedData = JSON.parse(jsonString); + const restored = SurveyEditorUndoRedo.fromJSON(parsedData); + + expect(restored.getCurrentState()).toEqual(undoRedo.getCurrentState()); + expect(restored.getHistoryLength()).toBe(undoRedo.getHistoryLength()); + expect(restored.getCurrentIndex()).toBe(undoRedo.getCurrentIndex()); + }); + + test('should handle large surveys correctly', () => { + const largeSurvey = createLargeSurvey(10); + undoRedo.commit(largeSurvey, 'Added large survey'); + + const jsonData = undoRedo.toJSON(); + const restored = SurveyEditorUndoRedo.fromJSON(jsonData); + + expect(restored.getCurrentState()).toEqual(undoRedo.getCurrentState()); + + const originalMemory = undoRedo.getMemoryUsage(); + const restoredMemory = restored.getMemoryUsage(); + expect(restoredMemory.totalMB).toBeCloseTo(originalMemory.totalMB, 1); + }); + + test('should preserve functionality after multiple round-trips', () => { + // First round-trip + let jsonData = undoRedo.toJSON(); + let restored = SurveyEditorUndoRedo.fromJSON(jsonData); + + // Second round-trip + jsonData = restored.toJSON(); + restored = SurveyEditorUndoRedo.fromJSON(jsonData); + + // Third round-trip + jsonData = restored.toJSON(); + restored = SurveyEditorUndoRedo.fromJSON(jsonData); + + // Should still work correctly + expect(restored.getCurrentState()).toEqual(undoRedo.getCurrentState()); + expect(restored.getHistoryLength()).toBe(undoRedo.getHistoryLength()); + + // Test functionality + const undoResult = restored.undo(); + expect(undoResult).toEqual(survey1); + + const redoResult = restored.redo(); + expect(redoResult).toEqual(survey2); + }); + }); + + describe('Integration with existing functionality', () => { + test('should work with jumpToIndex after restoration', () => { + const jsonData = undoRedo.toJSON(); + const restored = SurveyEditorUndoRedo.fromJSON(jsonData); + + const jumpResult = restored.jumpToIndex(0); + expect(jumpResult).toEqual(initialSurvey); + expect(restored.getCurrentIndex()).toBe(0); + + expect(restored.canJumpToIndex(1)).toBe(true); + expect(restored.canJumpToIndex(2)).toBe(true); + }); + + test('should work with memory cleanup after restoration', () => { + // Create instance with small memory limit + const limitedUndoRedo = new SurveyEditorUndoRedo(initialSurvey, { + maxTotalMemoryMB: 0.001, + minHistorySize: 2, + maxHistorySize: 5 + }); + + // Add several large surveys + for (let i = 0; i < 5; i++) { + limitedUndoRedo.commit(createLargeSurvey(5), `Large survey ${i}`); + } + + const jsonData = limitedUndoRedo.toJSON(); + const restored = SurveyEditorUndoRedo.fromJSON(jsonData); + + // Should maintain the same memory characteristics + const originalMemory = limitedUndoRedo.getMemoryUsage(); + const restoredMemory = restored.getMemoryUsage(); + + expect(restoredMemory.entries).toBe(originalMemory.entries); + expect(restoredMemory.entries).toBeGreaterThanOrEqual(2); // Minimum history size + }); + + test('should continue working after restoration and new commits', () => { + const jsonData = undoRedo.toJSON(); + const restored = SurveyEditorUndoRedo.fromJSON(jsonData); + + // Add new state to restored instance + const newSurvey = createSurvey('new-after-restore', 'New After Restore'); + restored.commit(newSurvey, 'Added after restoration'); + + expect(restored.getCurrentState()).toEqual(newSurvey); + expect(restored.getHistoryLength()).toBe(4); + expect(restored.canUndo()).toBe(true); + + // Should be able to undo to previous states + expect(restored.undo()).toEqual(survey2); + expect(restored.undo()).toEqual(survey1); + expect(restored.undo()).toEqual(initialSurvey); + }); + }); +}); diff --git a/src/__tests__/validity.test.ts b/src/__tests__/validity.test.ts deleted file mode 100644 index cf4a4af..0000000 --- a/src/__tests__/validity.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { SurveyEngineCore } from "../engine"; -import { Survey } from "../data_types"; -import { flattenSurveyItemTree } from "../utils"; -import { checkSurveyItemValidity, checkSurveyItemsValidity } from "../validation-checkers"; - - -test('testing validations', () => { - const testSurvey: Survey = { - - versionId: 'wfdojsdfpo', - surveyDefinition: { - key: "root", - items: [ - { - key: 'root.G1', selectionMethod: { name: 'sequential' }, items: [ - { - key: 'root.G1.1', validations: [ - { - key: 'v1', type: 'soft', rule: { - name: 'responseHasKeysAny', - data: [ - { str: 'root.G1.1' }, - { str: '1.1' }, - { str: '1' }, - ] - } - }, - { - key: 'v2', type: 'hard', rule: { - name: 'responseHasKeysAny', - data: [ - { str: 'root.G1.1' }, - { str: '1.1' }, - { str: '2' }, - ] - } - } - ] - }, - ] - }, - { - key: 'root.G2', items: [ - { - key: 'root.G2.1', - validations: [ - { - key: 'v1', type: 'hard', rule: { - name: 'responseHasKeysAny', - data: [ - { str: 'root.G2.1' }, - { str: '1.1' }, - { str: '1' }, - ] - } - }, - { - key: 'v2', type: 'hard', rule: { - name: 'responseHasKeysAny', - data: [ - { str: 'root.G2.1' }, - { str: '1.1' }, - { str: '2' }, - ] - } - } - ] - }, - ] - }, - ], - } - }; - - - const surveyE = new SurveyEngineCore( - testSurvey, - ); - - let items = flattenSurveyItemTree(surveyE.getRenderedSurvey()); - let i1 = items.find(it => it.key === 'root.G1.1'); - let i2 = items.find(it => it.key === 'root.G2.1'); - if (!i1 || !i2) { - throw Error('should not be undefined'); - } - - let vRes = checkSurveyItemValidity(i1); - expect(vRes.hard).toBeFalsy(); - expect(vRes.soft).toBeFalsy(); - vRes = checkSurveyItemValidity(i2); - expect(vRes.hard).toBeFalsy(); - expect(vRes.soft).toBeTruthy(); - - surveyE.setResponse('root.G1.1', { - key: '1', items: [{ key: '1', items: [{ key: '1' }, { key: '2' }] }] - }); - surveyE.setResponse('root.G2.1', { - key: '1', items: [{ key: '1', items: [{ key: '1' }, { key: '2' }] }] - }); - - items = flattenSurveyItemTree(surveyE.getRenderedSurvey()); - i1 = items.find(it => it.key === 'root.G1.1'); - i2 = items.find(it => it.key === 'root.G2.1'); - if (!i1 || !i2) { - throw Error('should not be undefined'); - } - - vRes = checkSurveyItemValidity(i1); - expect(vRes.hard).toBeTruthy(); - expect(vRes.soft).toBeTruthy(); - vRes = checkSurveyItemValidity(i2); - expect(vRes.hard).toBeTruthy(); - expect(vRes.soft).toBeTruthy(); -}); - - -test('testing multiple survey items validation', () => { - const testSurvey: Survey = { - - versionId: 'wfdojsdfpo', - surveyDefinition: { - key: "root", - items: [ - { - key: 'root.G1', selectionMethod: { name: 'sequential' }, items: [ - { - key: 'root.G1.1', validations: [ - { - key: 'v1', type: 'soft', rule: { - name: 'responseHasKeysAny', - data: [ - { str: 'root.G1.1' }, - { str: '1.1' }, - { str: '1' }, - ] - } - }, - { - key: 'v2', type: 'hard', rule: { - name: 'responseHasKeysAny', - data: [ - { str: 'root.G1.1' }, - { str: '1.1' }, - { str: '2' }, - ] - } - } - ] - }, - ] - }, - { - key: 'root.G2', items: [ - { - key: 'root.G2.1', - validations: [ - { - key: 'v1', type: 'hard', rule: { - name: 'responseHasKeysAny', - data: [ - { str: 'root.G2.1' }, - { str: '1.1' }, - { str: '1' }, - ] - } - }, - { - key: 'v2', type: 'hard', rule: { - name: 'responseHasKeysAny', - data: [ - { str: 'root.G2.1' }, - { str: '1.1' }, - { str: '2' }, - ] - } - } - ] - }, - ] - }, - ], - } - }; - - - const surveyE = new SurveyEngineCore( - testSurvey, - ); - - let items = flattenSurveyItemTree(surveyE.getRenderedSurvey()); - let vRes = checkSurveyItemsValidity(items); - expect(vRes.hard).toBeFalsy(); - expect(vRes.soft).toBeFalsy(); - - surveyE.setResponse('root.G1.1', { - key: '1', items: [{ key: '1', items: [{ key: '1' }, { key: '2' }] }] - }); - - items = flattenSurveyItemTree(surveyE.getRenderedSurvey()); - vRes = checkSurveyItemsValidity(items); - expect(vRes.hard).toBeFalsy(); - expect(vRes.soft).toBeTruthy(); - - surveyE.setResponse('root.G1.1', { - key: '1', items: [{ key: '1', items: [{ key: '1' }, { key: '2' }] }] - }); - - items = flattenSurveyItemTree(surveyE.getRenderedSurvey()); - vRes = checkSurveyItemsValidity(items); - expect(vRes.hard).toBeFalsy(); - expect(vRes.soft).toBeTruthy(); - - surveyE.setResponse('root.G2.1', { - key: '1', items: [{ key: '1', items: [{ key: '1' }, { key: '2' }] }] - }); - - items = flattenSurveyItemTree(surveyE.getRenderedSurvey()); - vRes = checkSurveyItemsValidity(items); - expect(vRes.hard).toBeTruthy(); - expect(vRes.soft).toBeTruthy(); -}); - diff --git a/src/__tests__/value-references-type-lookup.test.ts b/src/__tests__/value-references-type-lookup.test.ts new file mode 100644 index 0000000..2d08005 --- /dev/null +++ b/src/__tests__/value-references-type-lookup.test.ts @@ -0,0 +1,1060 @@ +import { ScgMcgChoiceResponseConfig, ScgMcgOption, ScgMcgOptionBase, ScgMcgOptionWithTextInput } from '../survey/components/survey-item-component'; +import { ItemComponentType } from '../survey/components/types'; +import { ExpectedValueType } from '../survey/utils'; +import { ValueReference, ValueReferenceMethod } from '../survey/utils/value-reference'; +import { Survey } from '../survey/survey'; +import { SingleChoiceQuestionItem, MultipleChoiceQuestionItem, DisplayItem, GroupItem } from '../survey/items'; +import { ResponseVariableExpression } from '../expressions/expression'; +import { TemplateDefTypes } from '../expressions/template-value'; +import { ReferenceUsageType } from '../survey/utils/value-reference'; + + +describe('ScgMcgChoiceResponseConfig - Value References', () => { + let singleChoiceConfig: ScgMcgChoiceResponseConfig; + + beforeEach(() => { + singleChoiceConfig = new ScgMcgChoiceResponseConfig('scg', undefined, 'survey.test-item'); + }); + + describe('Basic functionality', () => { + it('should create ScgMcgChoiceResponseConfig with correct type', () => { + expect(singleChoiceConfig.componentType).toBe(ItemComponentType.SingleChoice); + expect(singleChoiceConfig.items).toEqual([]); + }); + + it('should initialize with default value references when no options', () => { + const valueRefs = singleChoiceConfig.valueReferences; + expect(valueRefs).toEqual({ + 'survey.test-item...get': ExpectedValueType.String, + 'survey.test-item...isDefined': ExpectedValueType.Boolean, + }); + }); + }); + + describe('ScgMcgOption value references', () => { + it('should return default value references for basic options', () => { + const option1 = new ScgMcgOption('option1', singleChoiceConfig.key.fullKey, singleChoiceConfig.key.parentItemKey.fullKey); + const option2 = new ScgMcgOption('option2', singleChoiceConfig.key.fullKey, singleChoiceConfig.key.parentItemKey.fullKey); + + singleChoiceConfig.items = [option1, option2]; + + const valueRefs = singleChoiceConfig.valueReferences; + expect(valueRefs).toEqual({ + 'survey.test-item...get': ExpectedValueType.String, + 'survey.test-item...isDefined': ExpectedValueType.Boolean, + }); + }); + + it('should return empty value references for single basic option', () => { + const option = new ScgMcgOption('option1', singleChoiceConfig.key.fullKey, singleChoiceConfig.key.parentItemKey.fullKey); + + expect(option.valueReferences).toEqual({}); + }); + }); + + describe('ScgMcgOptionWithTextInput value references', () => { + it('should return correct value references for option with text input', () => { + const optionWithInput = new ScgMcgOptionWithTextInput('optionText', singleChoiceConfig.key.fullKey, singleChoiceConfig.key.parentItemKey.fullKey); + + const valueRefs = optionWithInput.valueReferences; + + // Expected value references for an option with text input + const expectedGetRef = ValueReference.fromParts( + optionWithInput.key.parentItemKey, + ValueReferenceMethod.get, + optionWithInput.key + ).toString(); + + const expectedIsDefinedRef = ValueReference.fromParts( + optionWithInput.key.parentItemKey, + ValueReferenceMethod.isDefined, + optionWithInput.key + ).toString(); + + expect(valueRefs).toEqual({ + [expectedGetRef]: ExpectedValueType.String, + [expectedIsDefinedRef]: ExpectedValueType.Boolean, + }); + }); + + it('should aggregate value references from option with text input in choice config', () => { + const basicOption = new ScgMcgOption('option1', singleChoiceConfig.key.fullKey, singleChoiceConfig.key.parentItemKey.fullKey); + const optionWithInput = new ScgMcgOptionWithTextInput('optionText', singleChoiceConfig.key.fullKey, singleChoiceConfig.key.parentItemKey.fullKey); + + singleChoiceConfig.items = [basicOption, optionWithInput]; + + const valueRefs = singleChoiceConfig.valueReferences; + + // Should only contain references from the option with text input + const expectedGetRef = ValueReference.fromParts( + optionWithInput.key.parentItemKey, + ValueReferenceMethod.get, + optionWithInput.key + ).toString(); + + const expectedIsDefinedRef = ValueReference.fromParts( + optionWithInput.key.parentItemKey, + ValueReferenceMethod.isDefined, + optionWithInput.key + ).toString(); + + expect(valueRefs).toEqual({ + 'survey.test-item...get': ExpectedValueType.String, + 'survey.test-item...isDefined': ExpectedValueType.Boolean, + [expectedGetRef]: ExpectedValueType.String, + [expectedIsDefinedRef]: ExpectedValueType.Boolean, + }); + }); + + it('should aggregate value references from multiple options with text input', () => { + const optionWithInput1 = new ScgMcgOptionWithTextInput('optionText1', singleChoiceConfig.key.fullKey, singleChoiceConfig.key.parentItemKey.fullKey); + const optionWithInput2 = new ScgMcgOptionWithTextInput('optionText2', singleChoiceConfig.key.fullKey, singleChoiceConfig.key.parentItemKey.fullKey); + + singleChoiceConfig.items = [optionWithInput1, optionWithInput2]; + + const valueRefs = singleChoiceConfig.valueReferences; + + // Expected references for first option + const expectedGetRef1 = ValueReference.fromParts( + optionWithInput1.key.parentItemKey, + ValueReferenceMethod.get, + optionWithInput1.key + ).toString(); + + const expectedIsDefinedRef1 = ValueReference.fromParts( + optionWithInput1.key.parentItemKey, + ValueReferenceMethod.isDefined, + optionWithInput1.key + ).toString(); + + // Expected references for second option + const expectedGetRef2 = ValueReference.fromParts( + optionWithInput2.key.parentItemKey, + ValueReferenceMethod.get, + optionWithInput2.key + ).toString(); + + const expectedIsDefinedRef2 = ValueReference.fromParts( + optionWithInput2.key.parentItemKey, + ValueReferenceMethod.isDefined, + optionWithInput2.key + ).toString(); + + expect(valueRefs).toEqual({ + 'survey.test-item...get': ExpectedValueType.String, + 'survey.test-item...isDefined': ExpectedValueType.Boolean, + [expectedGetRef1]: ExpectedValueType.String, + [expectedIsDefinedRef1]: ExpectedValueType.Boolean, + [expectedGetRef2]: ExpectedValueType.String, + [expectedIsDefinedRef2]: ExpectedValueType.Boolean, + }); + }); + }); + + describe('Mixed options value references', () => { + it('should correctly aggregate value references from mixed option types', () => { + const basicOption1 = new ScgMcgOption('option1', singleChoiceConfig.key.fullKey, singleChoiceConfig.key.parentItemKey.fullKey); + const optionWithInput = new ScgMcgOptionWithTextInput('optionText', singleChoiceConfig.key.fullKey, singleChoiceConfig.key.parentItemKey.fullKey); + const basicOption2 = new ScgMcgOption('option2', singleChoiceConfig.key.fullKey, singleChoiceConfig.key.parentItemKey.fullKey); + + singleChoiceConfig.items = [basicOption1, optionWithInput, basicOption2]; + + const valueRefs = singleChoiceConfig.valueReferences; + + // Should only contain references from the option with text input + const expectedGetRef = ValueReference.fromParts( + optionWithInput.key.parentItemKey, + ValueReferenceMethod.get, + optionWithInput.key + ).toString(); + + const expectedIsDefinedRef = ValueReference.fromParts( + optionWithInput.key.parentItemKey, + ValueReferenceMethod.isDefined, + optionWithInput.key + ).toString(); + + expect(valueRefs).toEqual({ + 'survey.test-item...get': ExpectedValueType.String, + 'survey.test-item...isDefined': ExpectedValueType.Boolean, + [expectedGetRef]: ExpectedValueType.String, + [expectedIsDefinedRef]: ExpectedValueType.Boolean, + }); + }); + }); + + describe('Value reference format validation', () => { + it('should generate correctly formatted value reference strings', () => { + const optionWithInput = new ScgMcgOptionWithTextInput('myOption', singleChoiceConfig.key.fullKey, 'survey.question1'); + + const valueRefs = optionWithInput.valueReferences; + const refKeys = Object.keys(valueRefs); + + expect(refKeys).toHaveLength(2); + + // Check that the references follow the expected format (using ... as separator) + const getRef = refKeys.find(key => key.includes('...get...')); + const isDefinedRef = refKeys.find(key => key.includes('...isDefined...')); + + expect(getRef).toBeDefined(); + expect(isDefinedRef).toBeDefined(); + + // Verify the format includes the item key and component key + expect(getRef).toContain('survey.question1'); + expect(getRef).toContain('scg.myOption'); + expect(isDefinedRef).toContain('survey.question1'); + expect(isDefinedRef).toContain('scg.myOption'); + }); + }); + + describe('Edge cases', () => { + it('should handle deeply nested component keys', () => { + const nestedSingleChoice = new ScgMcgChoiceResponseConfig('scg', 'parent.component', 'survey.page1.question1'); + const optionWithInput = new ScgMcgOptionWithTextInput('option1', nestedSingleChoice.key.fullKey, nestedSingleChoice.key.parentItemKey.fullKey); + + nestedSingleChoice.items = [optionWithInput]; + + const valueRefs = nestedSingleChoice.valueReferences; + + expect(Object.keys(valueRefs)).toHaveLength(4); + + // Verify that nested keys are handled correctly + const refKeys = Object.keys(valueRefs); + refKeys.forEach(key => { + const valRef = new ValueReference(key); + if (valRef.slotKey !== undefined) { + expect(key).toContain('survey.page1.question1'); + expect(key).toContain('parent.component.scg.option1'); + } + }); + }); + + it('should handle empty options array', () => { + singleChoiceConfig.items = []; + + const valueRefs = singleChoiceConfig.valueReferences; + + expect(valueRefs).toEqual({ + 'survey.test-item...get': ExpectedValueType.String, + 'survey.test-item...isDefined': ExpectedValueType.Boolean, + }); + }); + + it('should handle undefined options', () => { + // Reset options to undefined + singleChoiceConfig.items = undefined as unknown as ScgMcgOptionBase[]; + + const valueRefs = singleChoiceConfig.valueReferences; + + expect(valueRefs).toEqual({ + 'survey.test-item...get': ExpectedValueType.String, + 'survey.test-item...isDefined': ExpectedValueType.Boolean, + }); + }); + }); +}); + +describe('Survey - getResponseValueReferences', () => { + let survey: Survey; + + beforeEach(() => { + survey = new Survey('test-survey'); + }); + + describe('Empty and non-question items', () => { + it('should return empty object for survey with no items', () => { + // Create completely empty survey + const emptySurvey = new Survey('empty'); + emptySurvey.surveyItems = {}; // Remove even the root item + + const valueRefs = emptySurvey.getResponseValueReferences(); + expect(valueRefs).toEqual({}); + }); + + it('should return empty object for survey with only root group item', () => { + const valueRefs = survey.getResponseValueReferences(); + expect(valueRefs).toEqual({}); + }); + + it('should return empty object for survey with only display items', () => { + const displayItem = new DisplayItem('test-survey.display1'); + survey.surveyItems['test-survey.display1'] = displayItem; + + const valueRefs = survey.getResponseValueReferences(); + expect(valueRefs).toEqual({}); + }); + + it('should return empty object for survey with only group items', () => { + const groupItem = new GroupItem('test-survey.group1'); + survey.surveyItems['test-survey.group1'] = groupItem; + + const valueRefs = survey.getResponseValueReferences(); + expect(valueRefs).toEqual({}); + }); + }); + + describe('Single choice questions', () => { + it('should return value references for single choice with basic options', () => { + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + const option1 = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + const option2 = new ScgMcgOption('option2', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + + questionItem.responseConfig.items = [option1, option2]; + survey.surveyItems['test-survey.question1'] = questionItem; + + const valueRefs = survey.getResponseValueReferences(); + + // Single choice should have its own references (get and isDefined for the main response) + const expectedGetRef = ValueReference.fromParts( + questionItem.key, + ValueReferenceMethod.get + ).toString(); + + const expectedIsDefinedRef = ValueReference.fromParts( + questionItem.key, + ValueReferenceMethod.isDefined + ).toString(); + + expect(valueRefs).toEqual({ + [expectedGetRef]: ExpectedValueType.String, + [expectedIsDefinedRef]: ExpectedValueType.Boolean, + }); + }); + + it('should return value references for single choice with text input options', () => { + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + const basicOption = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + const optionWithInput = new ScgMcgOptionWithTextInput('optionText', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + + questionItem.responseConfig.items = [basicOption, optionWithInput]; + survey.surveyItems['test-survey.question1'] = questionItem; + + const valueRefs = survey.getResponseValueReferences(); + + // Should include main response references and option with text input references + const expectedMainGetRef = ValueReference.fromParts( + questionItem.key, + ValueReferenceMethod.get + ).toString(); + + const expectedMainIsDefinedRef = ValueReference.fromParts( + questionItem.key, + ValueReferenceMethod.isDefined + ).toString(); + + const expectedOptionGetRef = ValueReference.fromParts( + optionWithInput.key.parentItemKey, + ValueReferenceMethod.get, + optionWithInput.key + ).toString(); + + const expectedOptionIsDefinedRef = ValueReference.fromParts( + optionWithInput.key.parentItemKey, + ValueReferenceMethod.isDefined, + optionWithInput.key + ).toString(); + + expect(valueRefs).toEqual({ + [expectedMainGetRef]: ExpectedValueType.String, + [expectedMainIsDefinedRef]: ExpectedValueType.Boolean, + [expectedOptionGetRef]: ExpectedValueType.String, + [expectedOptionIsDefinedRef]: ExpectedValueType.Boolean, + }); + }); + + it('should return value references for multiple single choice questions', () => { + const questionItem1 = new SingleChoiceQuestionItem('test-survey.question1'); + const option1 = new ScgMcgOption('option1', questionItem1.responseConfig.key.fullKey, questionItem1.key.fullKey); + questionItem1.responseConfig.items = [option1]; + + const questionItem2 = new SingleChoiceQuestionItem('test-survey.question2'); + const optionWithInput = new ScgMcgOptionWithTextInput('optionText', questionItem2.responseConfig.key.fullKey, questionItem2.key.fullKey); + questionItem2.responseConfig.items = [optionWithInput]; + + survey.surveyItems['test-survey.question1'] = questionItem1; + survey.surveyItems['test-survey.question2'] = questionItem2; + + const valueRefs = survey.getResponseValueReferences(); + + expect(Object.keys(valueRefs)).toHaveLength(6); // 2 refs per question1, 4 refs for question2 + + // Verify all questions contribute their references + const refKeys = Object.keys(valueRefs); + expect(refKeys.some(key => key.includes('test-survey.question1'))).toBe(true); + expect(refKeys.some(key => key.includes('test-survey.question2'))).toBe(true); + }); + }); + + describe('Multiple choice questions', () => { + it('should return value references for multiple choice with basic options', () => { + const questionItem = new MultipleChoiceQuestionItem('test-survey.mcq1'); + const option1 = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + const option2 = new ScgMcgOption('option2', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + + questionItem.responseConfig.items = [option1, option2]; + survey.surveyItems['test-survey.mcq1'] = questionItem; + + const valueRefs = survey.getResponseValueReferences(); + + // Multiple choice should have its own references + expect(Object.keys(valueRefs)).toHaveLength(2); + + const refKeys = Object.keys(valueRefs); + expect(refKeys.some(key => key.includes('test-survey.mcq1'))).toBe(true); + expect(refKeys.some(key => key.includes('get'))).toBe(true); + expect(refKeys.some(key => key.includes('isDefined'))).toBe(true); + }); + }); + + describe('Mixed question types', () => { + it('should aggregate value references from mixed question types', () => { + const singleChoice = new SingleChoiceQuestionItem('test-survey.scq1'); + const scOption = new ScgMcgOption('option1', singleChoice.responseConfig.key.fullKey, singleChoice.key.fullKey); + singleChoice.responseConfig.items = [scOption]; + + const multipleChoice = new MultipleChoiceQuestionItem('test-survey.mcq1'); + const mcOptionWithInput = new ScgMcgOptionWithTextInput('optionText', multipleChoice.responseConfig.key.fullKey, multipleChoice.key.fullKey); + multipleChoice.responseConfig.items = [mcOptionWithInput]; + + const displayItem = new DisplayItem('test-survey.display1'); // Should be ignored + + survey.surveyItems['test-survey.scq1'] = singleChoice; + survey.surveyItems['test-survey.mcq1'] = multipleChoice; + survey.surveyItems['test-survey.display1'] = displayItem; + + const valueRefs = survey.getResponseValueReferences(); + + // Should have references from both question types but not display item + expect(Object.keys(valueRefs).length).toBe(6); + + const refKeys = Object.keys(valueRefs); + expect(refKeys.some(key => key.includes('test-survey.scq1'))).toBe(true); + expect(refKeys.some(key => key.includes('test-survey.mcq1'))).toBe(true); + expect(refKeys.some(key => key.includes('test-survey.display1'))).toBe(false); + }); + }); + + describe('Filtering by ExpectedValueType', () => { + beforeEach(() => { + // Set up survey with mixed value reference types + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + const optionWithInput = new ScgMcgOptionWithTextInput('optionText', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + questionItem.responseConfig.items = [optionWithInput]; + survey.surveyItems['test-survey.question1'] = questionItem; + }); + + it('should return only String type value references when filtered by String', () => { + const valueRefs = survey.getResponseValueReferences(ExpectedValueType.String); + + // All returned references should be String type + Object.values(valueRefs).forEach(type => { + expect(type).toBe(ExpectedValueType.String); + }); + + // Should have at least some String references + expect(Object.keys(valueRefs).length).toBe(2); + }); + + it('should return only Boolean type value references when filtered by Boolean', () => { + const valueRefs = survey.getResponseValueReferences(ExpectedValueType.Boolean); + + // All returned references should be Boolean type + Object.values(valueRefs).forEach(type => { + expect(type).toBe(ExpectedValueType.Boolean); + }); + + // Should have at least some Boolean references + expect(Object.keys(valueRefs).length).toBe(2); + }); + + it('should return empty object when filtered by type with no matches', () => { + const valueRefs = survey.getResponseValueReferences(ExpectedValueType.Number); + expect(valueRefs).toEqual({}); + }); + + it('should return all value references when no type filter is provided', () => { + const allValueRefs = survey.getResponseValueReferences(); + const stringRefs = survey.getResponseValueReferences(ExpectedValueType.String); + const booleanRefs = survey.getResponseValueReferences(ExpectedValueType.Boolean); + + // All refs should equal the sum of filtered refs + expect(Object.keys(allValueRefs).length).toBe( + Object.keys(stringRefs).length + Object.keys(booleanRefs).length + ); + }); + }); + + describe('Edge cases', () => { + it('should handle question items with undefined response config', () => { + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + // Artificially remove response config to test edge case + (questionItem as unknown as { responseConfig: undefined }).responseConfig = undefined; + + survey.surveyItems['test-survey.question1'] = questionItem; + + const valueRefs = survey.getResponseValueReferences(); + expect(valueRefs).toEqual({}); + }); + + it('should handle question items with null response config', () => { + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + // Artificially set response config to null to test edge case + (questionItem as unknown as { responseConfig: null }).responseConfig = null; + + survey.surveyItems['test-survey.question1'] = questionItem; + + const valueRefs = survey.getResponseValueReferences(); + expect(valueRefs).toEqual({}); + }); + + it('should handle deeply nested question items', () => { + const questionItem = new SingleChoiceQuestionItem('test-survey.page1.section1.question1'); + const option = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + questionItem.responseConfig.items = [option]; + + survey.surveyItems['test-survey.page1.section1.question1'] = questionItem; + + const valueRefs = survey.getResponseValueReferences(); + + expect(Object.keys(valueRefs).length).toBeGreaterThan(0); + const refKeys = Object.keys(valueRefs); + expect(refKeys.some(key => key.includes('test-survey.page1.section1.question1'))).toBe(true); + }); + + it('should maintain reference integrity with complex option structures', () => { + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + const basicOption1 = new ScgMcgOption('basic1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + const optionWithInput1 = new ScgMcgOptionWithTextInput('text1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + const basicOption2 = new ScgMcgOption('basic2', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + const optionWithInput2 = new ScgMcgOptionWithTextInput('text2', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + + questionItem.responseConfig.items = [basicOption1, optionWithInput1, basicOption2, optionWithInput2]; + survey.surveyItems['test-survey.question1'] = questionItem; + + const valueRefs = survey.getResponseValueReferences(); + + // Should have main question references + 2 sets of option with text input references + expect(Object.keys(valueRefs).length).toBe(6); // 2 main + 2*2 option text inputs + + const stringRefs = survey.getResponseValueReferences(ExpectedValueType.String); + const booleanRefs = survey.getResponseValueReferences(ExpectedValueType.Boolean); + + expect(Object.keys(stringRefs).length).toBe(3); // 1 main + 2 option text inputs + expect(Object.keys(booleanRefs).length).toBe(3); // 1 main + 2 option text inputs + }); + + it('should handle survey with very large number of questions', () => { + // Create many questions to test performance and correctness + for (let i = 1; i <= 100; i++) { + const questionItem = new SingleChoiceQuestionItem(`test-survey.question${i}`); + const option = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + questionItem.responseConfig.items = [option]; + survey.surveyItems[`test-survey.question${i}`] = questionItem; + } + + const valueRefs = survey.getResponseValueReferences(); + + // Should have 2 references per question (get and isDefined) + expect(Object.keys(valueRefs).length).toBe(200); + + // Test filtering still works with many items + const stringRefs = survey.getResponseValueReferences(ExpectedValueType.String); + const booleanRefs = survey.getResponseValueReferences(ExpectedValueType.Boolean); + + expect(Object.keys(stringRefs).length).toBe(100); + expect(Object.keys(booleanRefs).length).toBe(100); + }); + }); +}); + +describe('Survey - getReferenceUsages', () => { + let survey: Survey; + + beforeEach(() => { + survey = new Survey('test-survey'); + }); + + describe('Empty and basic survey scenarios', () => { + it('should return empty array for survey with no items', () => { + // Create completely empty survey + const emptySurvey = new Survey('empty'); + emptySurvey.surveyItems = {}; // Remove even the root item + + const usages = emptySurvey.getReferenceUsages(); + expect(usages).toEqual([]); + }); + + it('should return empty array for survey with only root group item', () => { + const usages = survey.getReferenceUsages(); + expect(usages).toEqual([]); + }); + + it('should return empty array for survey with items that have no references', () => { + const displayItem = new DisplayItem('test-survey.display1'); + const groupItem = new GroupItem('test-survey.group1'); + + survey.surveyItems['test-survey.display1'] = displayItem; + survey.surveyItems['test-survey.group1'] = groupItem; + + const usages = survey.getReferenceUsages(); + expect(usages).toEqual([]); + }); + }); + + describe('Display conditions', () => { + it('should return reference usages from display conditions', () => { + const displayItem = new DisplayItem('test-survey.display1'); + + // Add display conditions with response variable references + displayItem.displayConditions = { + root: new ResponseVariableExpression('test-survey.question1...get'), + components: { + 'comp1': new ResponseVariableExpression('test-survey.question2...isDefined') + } + }; + + survey.surveyItems['test-survey.display1'] = displayItem; + + const usages = survey.getReferenceUsages(); + + expect(usages).toHaveLength(2); + + // Check root display condition usage + const rootUsage = usages.find(u => u.fullComponentKey === undefined); + expect(rootUsage).toBeDefined(); + expect(rootUsage?.fullItemKey).toBe('test-survey.display1'); + expect(rootUsage?.usageType).toBe(ReferenceUsageType.displayConditions); + expect(rootUsage?.valueReference.toString()).toBe('test-survey.question1...get'); + + // Check component display condition usage + const componentUsage = usages.find(u => u.fullComponentKey === 'comp1'); + expect(componentUsage).toBeDefined(); + expect(componentUsage?.fullItemKey).toBe('test-survey.display1'); + expect(componentUsage?.usageType).toBe(ReferenceUsageType.displayConditions); + expect(componentUsage?.valueReference.toString()).toBe('test-survey.question2...isDefined'); + }); + }); + + describe('Template values', () => { + it('should return reference usages from template values', () => { + const displayItem = new DisplayItem('test-survey.display1'); + + // Add template values with response variable references + displayItem.templateValues = { + 'template1': { + type: TemplateDefTypes.Default, + returnType: ExpectedValueType.String, + expression: new ResponseVariableExpression('test-survey.question1...get') + }, + 'template2': { + type: TemplateDefTypes.Default, + returnType: ExpectedValueType.Boolean, + expression: new ResponseVariableExpression('test-survey.question2...isDefined') + } + }; + + survey.surveyItems['test-survey.display1'] = displayItem; + + const usages = survey.getReferenceUsages(); + + expect(usages).toHaveLength(2); + + // Check first template value usage + const template1Usage = usages.find(u => u.fullComponentKey === 'template1'); + expect(template1Usage).toBeDefined(); + expect(template1Usage?.fullItemKey).toBe('test-survey.display1'); + expect(template1Usage?.usageType).toBe(ReferenceUsageType.templateValues); + expect(template1Usage?.valueReference.toString()).toBe('test-survey.question1...get'); + + // Check second template value usage + const template2Usage = usages.find(u => u.fullComponentKey === 'template2'); + expect(template2Usage).toBeDefined(); + expect(template2Usage?.fullItemKey).toBe('test-survey.display1'); + expect(template2Usage?.usageType).toBe(ReferenceUsageType.templateValues); + expect(template2Usage?.valueReference.toString()).toBe('test-survey.question2...isDefined'); + }); + }); + + describe('Disabled conditions', () => { + it('should return reference usages from disabled conditions', () => { + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + + // Add disabled conditions with response variable references + questionItem.disabledConditions = { + components: { + 'rg.option1': new ResponseVariableExpression('test-survey.question2...get'), + 'rg.option2': new ResponseVariableExpression('test-survey.question3...isDefined') + } + }; + + survey.surveyItems['test-survey.question1'] = questionItem; + + const usages = survey.getReferenceUsages(); + + expect(usages).toHaveLength(2); + + // Check first disabled condition usage + const option1Usage = usages.find(u => u.fullComponentKey === 'rg.option1'); + expect(option1Usage).toBeDefined(); + expect(option1Usage?.fullItemKey).toBe('test-survey.question1'); + expect(option1Usage?.usageType).toBe(ReferenceUsageType.disabledConditions); + expect(option1Usage?.valueReference.toString()).toBe('test-survey.question2...get'); + + // Check second disabled condition usage + const option2Usage = usages.find(u => u.fullComponentKey === 'rg.option2'); + expect(option2Usage).toBeDefined(); + expect(option2Usage?.fullItemKey).toBe('test-survey.question1'); + expect(option2Usage?.usageType).toBe(ReferenceUsageType.disabledConditions); + expect(option2Usage?.valueReference.toString()).toBe('test-survey.question3...isDefined'); + }); + }); + + describe('Validations', () => { + it('should return reference usages from validations', () => { + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + + // Add validations with response variable references + questionItem.validations = { + 'validation1': new ResponseVariableExpression('test-survey.question2...get'), + 'validation2': new ResponseVariableExpression('test-survey.question3...isDefined') + }; + + survey.surveyItems['test-survey.question1'] = questionItem; + + const usages = survey.getReferenceUsages(); + + expect(usages).toHaveLength(2); + + // Check first validation usage + const validation1Usage = usages.find(u => u.fullComponentKey === 'validation1'); + expect(validation1Usage).toBeDefined(); + expect(validation1Usage?.fullItemKey).toBe('test-survey.question1'); + expect(validation1Usage?.usageType).toBe(ReferenceUsageType.validations); + expect(validation1Usage?.valueReference.toString()).toBe('test-survey.question2...get'); + + // Check second validation usage + const validation2Usage = usages.find(u => u.fullComponentKey === 'validation2'); + expect(validation2Usage).toBeDefined(); + expect(validation2Usage?.fullItemKey).toBe('test-survey.question1'); + expect(validation2Usage?.usageType).toBe(ReferenceUsageType.validations); + expect(validation2Usage?.valueReference.toString()).toBe('test-survey.question3...isDefined'); + }); + }); + + describe('Mixed usage types', () => { + it('should return reference usages from all usage types combined', () => { + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + + // Add multiple types of references + questionItem.displayConditions = { + root: new ResponseVariableExpression('test-survey.q1...get') + }; + + questionItem.templateValues = { + 'template1': { + type: TemplateDefTypes.Default, + returnType: ExpectedValueType.String, + expression: new ResponseVariableExpression('test-survey.q2...get') + } + }; + + questionItem.disabledConditions = { + components: { + 'rg.option1': new ResponseVariableExpression('test-survey.q3...isDefined') + } + }; + + questionItem.validations = { + 'validation1': new ResponseVariableExpression('test-survey.q4...get') + }; + + survey.surveyItems['test-survey.question1'] = questionItem; + + const usages = survey.getReferenceUsages(); + + expect(usages).toHaveLength(4); + + // Check that all usage types are present + const usageTypes = usages.map(u => u.usageType); + expect(usageTypes).toContain(ReferenceUsageType.displayConditions); + expect(usageTypes).toContain(ReferenceUsageType.templateValues); + expect(usageTypes).toContain(ReferenceUsageType.disabledConditions); + expect(usageTypes).toContain(ReferenceUsageType.validations); + }); + + it('should aggregate reference usages from multiple items', () => { + const displayItem = new DisplayItem('test-survey.display1'); + displayItem.displayConditions = { + root: new ResponseVariableExpression('test-survey.q1...get') + }; + + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + questionItem.validations = { + 'validation1': new ResponseVariableExpression('test-survey.q2...isDefined') + }; + + const groupItem = new GroupItem('test-survey.group1'); + groupItem.displayConditions = { + root: new ResponseVariableExpression('test-survey.q3...get') + }; + + survey.surveyItems['test-survey.display1'] = displayItem; + survey.surveyItems['test-survey.question1'] = questionItem; + survey.surveyItems['test-survey.group1'] = groupItem; + + const usages = survey.getReferenceUsages(); + + expect(usages).toHaveLength(3); + + // Check that all items contributed their references + const itemKeys = usages.map(u => u.fullItemKey); + expect(itemKeys).toContain('test-survey.display1'); + expect(itemKeys).toContain('test-survey.question1'); + expect(itemKeys).toContain('test-survey.group1'); + }); + }); + + describe('Filtering by forItemKey', () => { + beforeEach(() => { + // Set up survey with multiple items + const displayItem = new DisplayItem('test-survey.display1'); + displayItem.displayConditions = { + root: new ResponseVariableExpression('test-survey.q1...get') + }; + + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + questionItem.validations = { + 'validation1': new ResponseVariableExpression('test-survey.q2...isDefined') + }; + + const nestedDisplayItem = new DisplayItem('test-survey.group1.display2'); + nestedDisplayItem.templateValues = { + 'template1': { + type: TemplateDefTypes.Default, + returnType: ExpectedValueType.String, + expression: new ResponseVariableExpression('test-survey.q3...get') + } + }; + + survey.surveyItems['test-survey.display1'] = displayItem; + survey.surveyItems['test-survey.question1'] = questionItem; + survey.surveyItems['test-survey.group1.display2'] = nestedDisplayItem; + }); + + it('should return all usages when no forItemKey is provided', () => { + const usages = survey.getReferenceUsages(); + expect(usages).toHaveLength(3); + }); + + it('should return only usages from specific item when forItemKey matches exactly', () => { + const usages = survey.getReferenceUsages('test-survey.question1'); + + expect(usages).toHaveLength(1); + expect(usages[0].fullItemKey).toBe('test-survey.question1'); + expect(usages[0].usageType).toBe(ReferenceUsageType.validations); + }); + + it('should return usages from item and its children when forItemKey is a parent', () => { + const usages = survey.getReferenceUsages('test-survey.group1'); + + expect(usages).toHaveLength(1); + expect(usages[0].fullItemKey).toBe('test-survey.group1.display2'); + expect(usages[0].usageType).toBe(ReferenceUsageType.templateValues); + }); + + it('should return usages from all items under the specified parent key', () => { + // Add another nested item under group1 + const anotherNestedItem = new DisplayItem('test-survey.group1.display3'); + anotherNestedItem.displayConditions = { + root: new ResponseVariableExpression('test-survey.q4...get') + }; + survey.surveyItems['test-survey.group1.display3'] = anotherNestedItem; + + const usages = survey.getReferenceUsages('test-survey.group1'); + + expect(usages).toHaveLength(2); + const itemKeys = usages.map(u => u.fullItemKey); + expect(itemKeys).toContain('test-survey.group1.display2'); + expect(itemKeys).toContain('test-survey.group1.display3'); + }); + + it('should return empty array when forItemKey matches no items', () => { + const usages = survey.getReferenceUsages('test-survey.nonexistent'); + expect(usages).toEqual([]); + }); + + it('should return empty array when forItemKey matches item with no references', () => { + const emptyItem = new DisplayItem('test-survey.empty'); + survey.surveyItems['test-survey.empty'] = emptyItem; + + const usages = survey.getReferenceUsages('test-survey.empty'); + expect(usages).toEqual([]); + }); + }); +}); + +describe('Survey - findInvalidReferenceUsages', () => { + let survey: Survey; + + beforeEach(() => { + survey = new Survey('test-survey'); + }); + + describe('Invalid reference detection', () => { + it('should return empty array when no invalid references exist', () => { + // Create a question that generates valid value references + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + const option = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + questionItem.responseConfig.items = [option]; + survey.surveyItems['test-survey.question1'] = questionItem; + + // Create an item that references the valid value reference + const displayItem = new DisplayItem('test-survey.display1'); + displayItem.displayConditions = { + root: new ResponseVariableExpression('test-survey.question1...get') + }; + survey.surveyItems['test-survey.display1'] = displayItem; + + const invalidUsages = survey.findInvalidReferenceUsages(); + expect(invalidUsages).toEqual([]); + }); + + it('should return invalid usages when expressions reference non-existing response value references', () => { + // Create a question that generates valid value references + const questionItem = new SingleChoiceQuestionItem('test-survey.question1'); + const option = new ScgMcgOption('option1', questionItem.responseConfig.key.fullKey, questionItem.key.fullKey); + questionItem.responseConfig.items = [option]; + survey.surveyItems['test-survey.question1'] = questionItem; + + // Create items that reference both valid and invalid value references + const displayItem = new DisplayItem('test-survey.display1'); + displayItem.displayConditions = { + root: new ResponseVariableExpression('test-survey.question1...get') // Valid reference + }; + displayItem.templateValues = { + 'template1': { + type: TemplateDefTypes.Default, + returnType: ExpectedValueType.String, + expression: new ResponseVariableExpression('test-survey.nonexistent...get') // Invalid reference + } + }; + survey.surveyItems['test-survey.display1'] = displayItem; + + const questionItem2 = new SingleChoiceQuestionItem('test-survey.question2'); + questionItem2.validations = { + 'validation1': new ResponseVariableExpression('test-survey.question1...isDefined'), // Valid reference + 'validation2': new ResponseVariableExpression('test-survey.another-nonexistent...isDefined') // Invalid reference + }; + survey.surveyItems['test-survey.question2'] = questionItem2; + + const invalidUsages = survey.findInvalidReferenceUsages(); + + expect(invalidUsages).toHaveLength(2); + + // Check first invalid usage (from display item template values) + const templateInvalidUsage = invalidUsages.find(u => + u.fullItemKey === 'test-survey.display1' && + u.fullComponentKey === 'template1' && + u.usageType === ReferenceUsageType.templateValues + ); + expect(templateInvalidUsage).toBeDefined(); + expect(templateInvalidUsage?.valueReference.toString()).toBe('test-survey.nonexistent...get'); + + // Check second invalid usage (from question validation) + const validationInvalidUsage = invalidUsages.find(u => + u.fullItemKey === 'test-survey.question2' && + u.fullComponentKey === 'validation2' && + u.usageType === ReferenceUsageType.validations + ); + expect(validationInvalidUsage).toBeDefined(); + expect(validationInvalidUsage?.valueReference.toString()).toBe('test-survey.another-nonexistent...isDefined'); + }); + + it('should handle mixed scenarios with valid and invalid references across multiple usage types', () => { + // Create questions that generate valid value references + const questionItem1 = new SingleChoiceQuestionItem('test-survey.question1'); + const option1 = new ScgMcgOption('option1', questionItem1.responseConfig.key.fullKey, questionItem1.key.fullKey); + questionItem1.responseConfig.items = [option1]; + survey.surveyItems['test-survey.question1'] = questionItem1; + + const questionItem2 = new SingleChoiceQuestionItem('test-survey.question2'); + const optionWithInput = new ScgMcgOptionWithTextInput('optionText', questionItem2.responseConfig.key.fullKey, questionItem2.key.fullKey); + questionItem2.responseConfig.items = [optionWithInput]; + survey.surveyItems['test-survey.question2'] = questionItem2; + + // Create an item with multiple types of references (valid and invalid) + const complexItem = new SingleChoiceQuestionItem('test-survey.complex'); + + // Valid references + complexItem.displayConditions = { + root: new ResponseVariableExpression('test-survey.question1...get') // Valid + }; + + // Mix of valid and invalid references + complexItem.templateValues = { + 'validTemplate': { + type: TemplateDefTypes.Default, + returnType: ExpectedValueType.String, + expression: new ResponseVariableExpression('test-survey.question2...isDefined') // Valid + }, + 'invalidTemplate': { + type: TemplateDefTypes.Default, + returnType: ExpectedValueType.String, + expression: new ResponseVariableExpression('test-survey.missing-question...get') // Invalid + } + }; + + complexItem.disabledConditions = { + components: { + 'rg.option1': new ResponseVariableExpression('test-survey.question1...isDefined'), // Valid + 'rg.option2': new ResponseVariableExpression('test-survey.ghost-question...get') // Invalid + } + }; + + complexItem.validations = { + 'validValidation': new ResponseVariableExpression('test-survey.question2...get...scg.optionText'), // Valid reference to option with text input + 'invalidValidation': new ResponseVariableExpression('test-survey.void-question...isDefined') // Invalid + }; + + survey.surveyItems['test-survey.complex'] = complexItem; + + const invalidUsages = survey.findInvalidReferenceUsages(); + + expect(invalidUsages).toHaveLength(3); + + // Should only contain the invalid references + const invalidRefs = invalidUsages.map(u => u.valueReference.toString()); + expect(invalidRefs).toContain('test-survey.missing-question...get'); + expect(invalidRefs).toContain('test-survey.ghost-question...get'); + expect(invalidRefs).toContain('test-survey.void-question...isDefined'); + + // Should not contain valid references + expect(invalidRefs).not.toContain('test-survey.question1...get'); + expect(invalidRefs).not.toContain('test-survey.question1...isDefined'); + expect(invalidRefs).not.toContain('test-survey.question2...isDefined'); + expect(invalidRefs).not.toContain('test-survey.question2...get...scg.optionText'); + + // Verify each invalid usage has the correct metadata + const templateInvalid = invalidUsages.find(u => u.fullComponentKey === 'invalidTemplate'); + expect(templateInvalid?.usageType).toBe(ReferenceUsageType.templateValues); + expect(templateInvalid?.fullItemKey).toBe('test-survey.complex'); + + const disabledInvalid = invalidUsages.find(u => u.fullComponentKey === 'rg.option2'); + expect(disabledInvalid?.usageType).toBe(ReferenceUsageType.disabledConditions); + expect(disabledInvalid?.fullItemKey).toBe('test-survey.complex'); + + const validationInvalid = invalidUsages.find(u => u.fullComponentKey === 'invalidValidation'); + expect(validationInvalid?.usageType).toBe(ReferenceUsageType.validations); + expect(validationInvalid?.fullItemKey).toBe('test-survey.complex'); + }); + + it('should return empty array when survey has no items with expressions', () => { + // Create a survey with only display items that have no expressions + const displayItem = new DisplayItem('test-survey.display1'); + survey.surveyItems['test-survey.display1'] = displayItem; + + const invalidUsages = survey.findInvalidReferenceUsages(); + expect(invalidUsages).toEqual([]); + }); + }); +}); diff --git a/src/cqm-parser.ts b/src/cqm-parser.ts new file mode 100644 index 0000000..b490caf --- /dev/null +++ b/src/cqm-parser.ts @@ -0,0 +1,58 @@ +interface CQMPart { + bold: boolean + underline: boolean + textColor?: 'primary' | 'accent' + italic: boolean + content: string +} + + +export const parseCQM = (text?: string): CQMPart[] => { + if (!text) { return [] } + + const parts: CQMPart[] = [] + const currentPart: CQMPart = { + bold: false, + underline: false, + textColor: undefined, + italic: false, + content: "", + } + + const pushCurrentPart = () => { + if (currentPart.content) { + parts.push({ ...currentPart }) + + currentPart.content = "" + } + } + + for (let i = 0; i < text.length; i++) { + if (text[i] === "*" && text[i + 1] === "*") { + pushCurrentPart() + currentPart.bold = !currentPart.bold + i++ + } else if (text[i] === "_" && text[i + 1] === "_") { + pushCurrentPart() + currentPart.underline = !currentPart.underline + i++ + } else if (text[i] === "!" && text[i + 1] === "!") { + pushCurrentPart() + currentPart.textColor = currentPart.textColor === 'primary' ? undefined : 'primary' + i++ + } else if (text[i] === "~" && text[i + 1] === "~") { + pushCurrentPart() + currentPart.textColor = currentPart.textColor === 'accent' ? undefined : 'accent' + i++ + } else if (text[i] === "/" && text[i + 1] === "/") { + pushCurrentPart() + currentPart.italic = !currentPart.italic + i++ + } else { + currentPart.content += text[i] + } + } + + pushCurrentPart() + return parts +} \ No newline at end of file diff --git a/src/data_types/context.ts b/src/data_types/context.ts deleted file mode 100644 index cd24760..0000000 --- a/src/data_types/context.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { SurveyResponse } from "./response"; -import { ExpressionArg, Expression } from "./expression"; - -export interface SurveyContext { - previousResponses?: Array; - profile?: any; // TODO: define - mode?: string; - isLoggedIn?: boolean; - participantFlags?: { [key: string]: string }; - // TODO: have geolocation and other attributes -} - -export interface SurveyContextDef { - mode?: ExpressionArg; - previousResponses?: Expression[]; -} diff --git a/src/data_types/engine.ts b/src/data_types/engine.ts deleted file mode 100644 index c8dd784..0000000 --- a/src/data_types/engine.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { SurveySingleItemResponse } from "./response"; -import { SurveyContext } from "./context"; -import { SurveyGroupItem, SurveySingleItem } from "./survey-item"; - -export type ScreenSize = "small" | "large"; - -export interface SurveyEngineCoreInterface { - setContext: (context: SurveyContext) => void; - setResponse: (targetKey: string, response: any) => void; - - getRenderedSurvey: () => SurveyGroupItem; - getSurveyPages: (size?: ScreenSize) => SurveySingleItem[][] - questionDisplayed: (questionID: string) => void; // should be called by the client when displaying a question - getSurveyEndItem: () => SurveySingleItem | undefined; - - getResponses: () => SurveySingleItemResponse[]; -} diff --git a/src/data_types/expression.ts b/src/data_types/expression.ts index e05e3d2..1c2821a 100644 --- a/src/data_types/expression.ts +++ b/src/data_types/expression.ts @@ -2,6 +2,7 @@ export type SelectionMethodNames = 'sequential' | 'uniform' | 'highestPriority' export type SurveyContextRuleNames = 'LAST_RESPONSES_BY_KEY' | 'ALL_RESPONSES_SINCE' | 'RESPONSES_SINCE_BY_KEY'; export type SurveyPrefillRuleNames = 'GET_LAST_SURVEY_ITEM'; +// TODO: move to expression evaluator export type ClientSideSurveyExpName = // logic expression: diff --git a/src/data_types/index.ts b/src/data_types/index.ts index ebddb9a..5a3ea39 100644 --- a/src/data_types/index.ts +++ b/src/data_types/index.ts @@ -1,7 +1,2 @@ -export * from './expression'; -export * from './survey'; -export * from './survey-item'; -export * from './survey-item-component'; -export * from './context'; -export * from './response'; -export * from './engine'; + +export * from './legacy-types'; diff --git a/src/data_types/legacy-types.ts b/src/data_types/legacy-types.ts new file mode 100644 index 0000000..bd10676 --- /dev/null +++ b/src/data_types/legacy-types.ts @@ -0,0 +1,122 @@ +import { Expression } from "./expression"; +import { ExpressionArg } from "./expression"; + +// ---------------------------------------------------------------------- +export type LegacyItemComponent = LegacyItemComponentBase | LegacyItemGroupComponent | LegacyResponseComponent; + +interface LegacyItemComponentBase { + role: string; // purpose of the component + key?: string; // unique identifier + content?: Array; // array with item that are a sub-type of LocalizedObject + displayCondition?: Expression | boolean; + disabled?: Expression | boolean; + style?: Array<{ key: string, value: string }>; + description?: Array; // optional explanation to the content + properties?: LegacyComponentProperties; +} + +export interface LegacyResponseComponent extends LegacyItemComponentBase { + key: string; + dtype?: string; +} + +export interface LegacyItemGroupComponent extends LegacyItemComponentBase { + items: Array; + order?: Expression; +} + +export const isLegacyItemGroupComponent = (item: LegacyItemComponent): item is LegacyItemGroupComponent => { + const items = (item as LegacyItemGroupComponent).items; + return items !== undefined && items.length > 0; +} + +export interface LegacyComponentProperties { + min?: ExpressionArg | number; + max?: ExpressionArg | number; + stepSize?: ExpressionArg | number; + dateInputMode?: ExpressionArg | string; + pattern?: string; +} + +// ---------------------------------------------------------------------- +export type LegacyLocalizedObject = LegacyLocalizedString; + +export interface LegacyLocalizedObjectBase { + code: string; +} + +export interface LegacyLocalizedString extends LegacyLocalizedObjectBase { + parts: Array + resolvedText?: string; +} + + +export interface LegacySurvey { + id?: string; + props?: LegacySurveyProps; + prefillRules?: Expression[]; + //contextRules?: SurveyContextDef; + maxItemsPerPage?: { large: number, small: number }; + availableFor?: string; + requireLoginBeforeSubmission?: boolean; + // + surveyDefinition: LegacySurveyGroupItem; + published?: number; + unpublished?: number; + versionId: string; + metadata?: { + [key: string]: string + } +} + + +export interface LegacySurveyProps { + name?: LegacyLocalizedObject[]; + description?: LegacyLocalizedObject[]; + typicalDuration?: LegacyLocalizedObject[]; +} + +interface LegacySurveyItemBase { + key: string; + metadata?: { + [key: string]: string + } + follows?: Array; + condition?: Expression; + priority?: number; // can be used to sort items in the list +} + +export type LegacySurveyItem = LegacySurveyGroupItem | LegacySurveySingleItem; + +// ---------------------------------------------------------------------- +export interface LegacySurveyGroupItem extends LegacySurveyItemBase { + items: Array; + selectionMethod?: Expression; // what method to use to pick next item if ambigous - default uniform random +} + +export const isLegacySurveyGroupItem = (item: LegacySurveyItem): item is LegacySurveyGroupItem => { + const items = (item as LegacySurveyGroupItem).items; + return items !== undefined && items.length > 0; +} + +// ---------------------------------------------------------------------- +// Single Survey Items: +export type LegacySurveyItemTypes = + 'pageBreak' | 'test' | 'surveyEnd' + ; + +export interface LegacySurveySingleItem extends LegacySurveyItemBase { + type?: LegacySurveyItemTypes; + components?: LegacyItemGroupComponent; // any sub-type of ItemComponent + validations?: Array; + confidentialMode?: LegacyConfidentialMode; + mapToKey?: string; // if the response should be mapped to another key in confidential mode +} + +export interface LegacyValidation { + key: string; + type: 'soft' | 'hard'; // hard or softvalidation + rule: Expression | boolean; +} + +export type LegacyConfidentialMode = 'add' | 'replace'; diff --git a/src/data_types/response.ts b/src/data_types/response.ts deleted file mode 100644 index 2b2ebed..0000000 --- a/src/data_types/response.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ConfidentialMode } from "./survey-item"; - -export type TimestampType = 'rendered' | 'displayed' | 'responded'; - -export interface SurveyResponse { - key: string; - participantId?: string; - submittedAt: number; - openedAt?: number; - versionId: string; - responses: SurveySingleItemResponse[]; - context?: any; // key value pairs of data -} - -export type SurveyItemResponse = SurveySingleItemResponse | SurveyGroupItemResponse; - -interface SurveyItemResponseBase { - key: string; - meta?: ResponseMeta; -} - -export interface SurveySingleItemResponse extends SurveyItemResponseBase { - response?: ResponseItem; - confidentialMode?: ConfidentialMode; - mapToKey?: string; // if the response should be mapped to another key in confidential mode -} - -export interface SurveyGroupItemResponse extends SurveyItemResponseBase { - items: Array -} - -export const isSurveyGroupItemResponse = (item: SurveyGroupItemResponse | SurveyItemResponse): item is SurveyGroupItemResponse => { - const items = (item as SurveyGroupItemResponse).items; - return items !== undefined && items.length > 0; -} - -export interface ResponseItem { - key: string; - value?: string; - dtype?: string; - items?: ResponseItem[]; -} - -export interface ResponseMeta { - position: number; // position in the list - localeCode?: string; - // timestamps: - rendered: Array; - displayed: Array; - responded: Array; -} diff --git a/src/data_types/survey-item-component.ts b/src/data_types/survey-item-component.ts deleted file mode 100644 index fbe0b2a..0000000 --- a/src/data_types/survey-item-component.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Expression, ExpressionArg } from "./expression"; - -// ---------------------------------------------------------------------- -export type ItemComponent = ItemComponentBase | ItemGroupComponent | ResponseComponent; - -interface ItemComponentBase { - role: string; // purpose of the component - key?: string; // unique identifier - content?: Array; // array with item that are a sub-type of LocalizedObject - displayCondition?: Expression | boolean; - disabled?: Expression | boolean; - style?: Array<{ key: string, value: string }>; - description?: Array; // optional explanation to the content - properties?: ComponentProperties; -} - -export interface ResponseComponent extends ItemComponentBase { - key: string; - dtype?: string; -} - -export interface ItemGroupComponent extends ItemComponentBase { - items: Array; - order?: Expression; -} - -export const isItemGroupComponent = (item: ItemComponent): item is ItemGroupComponent => { - const items = (item as ItemGroupComponent).items; - return items !== undefined && items.length > 0; -} - -export interface ComponentProperties { - min?: ExpressionArg | number; - max?: ExpressionArg | number; - stepSize?: ExpressionArg | number; - dateInputMode?: ExpressionArg | string; - pattern?: string; -} - -// ---------------------------------------------------------------------- -export type LocalizedObject = LocalizedString | LocalizedMedia; - -export interface LocalizedObjectBase { - code: string; -} - -export interface LocalizedString extends LocalizedObjectBase { - parts: Array; // string and number in case of resolved expression - resolvedText?: string; -} - -export interface LocalizedMedia extends LocalizedObjectBase { - // TODO: define common properties - // TODO: define image object - // TODO: define video object - // TODO: define audio object -} diff --git a/src/data_types/survey-item.ts b/src/data_types/survey-item.ts deleted file mode 100644 index b3caa53..0000000 --- a/src/data_types/survey-item.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Expression } from './expression'; -import { ItemGroupComponent } from './survey-item-component'; - - -interface SurveyItemBase { - key: string; - metadata?: { - [key: string]: string - } - follows?: Array; - condition?: Expression; - priority?: number; // can be used to sort items in the list -} - -export type SurveyItem = SurveyGroupItem | SurveySingleItem; - -// ---------------------------------------------------------------------- -export interface SurveyGroupItem extends SurveyItemBase { - items: Array; - selectionMethod?: Expression; // what method to use to pick next item if ambigous - default uniform random -} - -export const isSurveyGroupItem = (item: SurveyItem): item is SurveyGroupItem => { - const items = (item as SurveyGroupItem).items; - return items !== undefined && items.length > 0; -} - -// ---------------------------------------------------------------------- -// Single Survey Items: -export type SurveyItemTypes = - 'pageBreak' | 'test' | 'surveyEnd' - ; - -export interface SurveySingleItem extends SurveyItemBase { - type?: SurveyItemTypes; - components?: ItemGroupComponent; // any sub-type of ItemComponent - validations?: Array; - confidentialMode?: ConfidentialMode; - mapToKey?: string; // if the response should be mapped to another key in confidential mode -} - -export interface Validation { - key: string; - type: 'soft' | 'hard'; // hard or softvalidation - rule: Expression | boolean; -} - -export type ConfidentialMode = 'add' | 'replace'; diff --git a/src/data_types/survey.ts b/src/data_types/survey.ts deleted file mode 100644 index a414e03..0000000 --- a/src/data_types/survey.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { LocalizedObject, SurveyGroupItem } from "."; -import { Expression } from "./expression"; -import { SurveyContextDef } from "./context"; - -export interface Survey { - id?: string; - props?: SurveyProps; - prefillRules?: Expression[]; - contextRules?: SurveyContextDef; - maxItemsPerPage?: { large: number, small: number }; - availableFor?: string; - requireLoginBeforeSubmission?: boolean; - // - surveyDefinition: SurveyGroupItem; - published?: number; - unpublished?: number; - versionId: string; - metadata?: { - [key: string]: string - } -} - - -export interface SurveyProps { - name?: LocalizedObject[]; - description?: LocalizedObject[]; - typicalDuration?: LocalizedObject[]; -} diff --git a/src/engine.ts b/src/engine.ts deleted file mode 100644 index 14a4f3e..0000000 --- a/src/engine.ts +++ /dev/null @@ -1,722 +0,0 @@ -import { - SurveyEngineCoreInterface, - SurveyContext, - TimestampType, - Expression, - SurveyItemResponse, - isSurveyGroupItemResponse, - SurveyGroupItem, - SurveyGroupItemResponse, - SurveyItem, - isSurveyGroupItem, - SurveySingleItemResponse, - ResponseItem, - SurveySingleItem, - ItemGroupComponent, - isItemGroupComponent, - LocalizedString, - LocalizedObject, - ComponentProperties, - ExpressionArg, - isExpression, - expressionArgParser, - Survey, - ScreenSize, - ResponseMeta, -} from "./data_types"; -import { - removeItemByKey, flattenSurveyItemTree -} from './utils'; -import { ExpressionEval } from "./expression-eval"; -import { SelectionMethod } from "./selection-method"; - -const initMeta: ResponseMeta = { - rendered: [], - displayed: [], - responded: [], - position: -1, - localeCode: '', -} - -export class SurveyEngineCore implements SurveyEngineCoreInterface { - private surveyDef: Survey; - private renderedSurvey: SurveyGroupItem; - private responses: SurveyGroupItemResponse; - private context: SurveyContext; - private prefills: SurveySingleItemResponse[]; - private openedAt: number; - - private evalEngine: ExpressionEval; - private showDebugMsg: boolean; - - constructor( - survey: Survey, - context?: SurveyContext, - prefills?: SurveySingleItemResponse[], - showDebugMsg?: boolean, - ) { - // console.log('core engine') - this.evalEngine = new ExpressionEval(); - - this.surveyDef = survey; - this.context = context ? context : {}; - this.prefills = prefills ? prefills : []; - this.showDebugMsg = showDebugMsg !== undefined ? showDebugMsg : false; - this.responses = this.initResponseObject(this.surveyDef.surveyDefinition); - this.renderedSurvey = { - key: survey.surveyDefinition.key, - items: [] - }; - this.openedAt = Date.now(); - this.setTimestampFor('rendered', survey.surveyDefinition.key); - this.initRenderedGroup(survey.surveyDefinition, survey.surveyDefinition.key); - } - - // PUBLIC METHODS - setContext(context: SurveyContext) { - this.context = context; - } - - setResponse(targetKey: string, response?: ResponseItem) { - const target = this.findResponseItem(targetKey); - if (!target) { - console.error('setResponse: cannot find target object for key: ' + targetKey); - return; - } - if (isSurveyGroupItemResponse(target)) { - console.error('setResponse: object is a response group - not defined: ' + targetKey); - return; - } - target.response = response; - this.setTimestampFor('responded', targetKey); - - // Re-render whole tree - this.reRenderGroup(this.renderedSurvey.key); - } - - getSurveyOpenedAt(): number { - return this.openedAt; - } - - getRenderedSurvey(): SurveyGroupItem { - return { - ...this.renderedSurvey, - items: this.renderedSurvey.items.slice() - }; - }; - - getSurveyPages(size?: ScreenSize): SurveySingleItem[][] { - const renderedSurvey = flattenSurveyItemTree(this.getRenderedSurvey()); - const pages = new Array(); - - if (!size) { - size = 'large'; - } - - let currentPage: SurveySingleItem[] = []; - - renderedSurvey.forEach(item => { - if (item.type === 'pageBreak') { - if (currentPage.length > 0) { - pages.push([...currentPage]); - currentPage = []; - } - return; - } - currentPage.push(item); - - if (!this.surveyDef.maxItemsPerPage) { - return; - } - let max = 0; - switch (size) { - case 'large': - max = this.surveyDef.maxItemsPerPage.large; - break; - case 'small': - max = this.surveyDef.maxItemsPerPage.small; - break - } - - if (currentPage.length >= max) { - pages.push([...currentPage]); - currentPage = []; - } - }); - if (currentPage.length > 0) { - pages.push([...currentPage]); - } - return pages; - } - - questionDisplayed(itemKey: string, localeCode?: string) { - this.setTimestampFor('displayed', itemKey, localeCode); - } - - getSurveyEndItem(): SurveySingleItem | undefined { - const renderedSurvey = flattenSurveyItemTree(this.getRenderedSurvey()); - return renderedSurvey.find(item => item.type === 'surveyEnd'); - } - - getResponses(): SurveySingleItemResponse[] { - const itemsInOrder = flattenSurveyItemTree(this.renderedSurvey); - const responses: SurveySingleItemResponse[] = []; - itemsInOrder.forEach((item, index) => { - if (item.type === 'pageBreak' || item.type === 'surveyEnd') { - return; - } - const obj = this.findResponseItem(item.key); - if (!obj) { - return; - } - if (!obj.meta) { - obj.meta = { ...initMeta }; - } - if (item.confidentialMode) { - obj.meta = { ...initMeta }; // reset meta - (obj as SurveySingleItemResponse).confidentialMode = item.confidentialMode; - (obj as SurveySingleItemResponse).mapToKey = item.mapToKey - } - obj.meta.position = index; - responses.push({ ...obj }); - }) - return responses; - } - - // INIT METHODS - private initResponseObject(qGroup: SurveyGroupItem): SurveyGroupItemResponse { - const respGroup: SurveyGroupItemResponse = { - key: qGroup.key, - meta: { - rendered: [], - displayed: [], - responded: [], - position: -1, - localeCode: '', - }, - items: [], - }; - - qGroup.items.forEach(item => { - if (isSurveyGroupItem(item)) { - respGroup.items.push(this.initResponseObject(item)); - } else { - if (item.type === 'pageBreak' || item.type === 'surveyEnd') { - return; - } - const prefill = this.prefills.find(ri => ri.key === item.key); - - const itemResp: SurveySingleItemResponse = { - key: item.key, - meta: { - rendered: [], - displayed: [], - responded: [], - position: -1, - localeCode: '', - }, - response: prefill ? prefill.response : undefined, - }; - respGroup.items.push(itemResp); - } - }); - - return respGroup; - } - - private sequentialRender(groupDef: SurveyGroupItem, parent: SurveyGroupItem, rerender?: boolean) { - let currentIndex = 0; - groupDef.items.forEach(itemDef => { - const itemCond = this.evalConditions(itemDef.condition); - const ind = parent.items.findIndex(rItem => rItem.key === itemDef.key); - if (ind < 0) { - if (itemCond) { - if (isSurveyGroupItem(itemDef)) { - this.addRenderedItem(itemDef, parent, currentIndex); - this.initRenderedGroup(itemDef, itemDef.key); - } else { - this.addRenderedItem(itemDef, parent, currentIndex); - } - } else { - return; - } - } else { - if (!itemCond) { - parent.items = removeItemByKey(parent.items, itemDef.key); - return - } - if (rerender) { - if (isSurveyGroupItem(itemDef)) { - this.reRenderGroup(itemDef.key); - } else { - parent.items[ind] = this.renderSingleSurveyItem(itemDef as SurveySingleItem, true); - } - } - } - currentIndex += 1; - }) - } - - private initRenderedGroup(groupDef: SurveyGroupItem, parentKey: string) { - const parent = this.findRenderedItem(parentKey) as SurveyGroupItem; - if (!parent) { - console.warn('initRenderedGroup: parent not found: ' + parentKey); - return; - } - - if (groupDef.selectionMethod && groupDef.selectionMethod.name === 'sequential') { - // simplified workflow: - this.sequentialRender(groupDef, parent); - return - } - - let nextItem = this.getNextItem(groupDef, parent, parent.key, false); - while (nextItem !== null) { - if (!nextItem) { - break; - } - this.addRenderedItem(nextItem, parent); - if (isSurveyGroupItem(nextItem)) { - this.initRenderedGroup(nextItem, nextItem.key); - } - nextItem = this.getNextItem(groupDef, parent, nextItem.key, false); - } - } - - private reRenderGroup(groupKey: string) { - const renderedGroup = this.findRenderedItem(groupKey); - if (!renderedGroup || !isSurveyGroupItem(renderedGroup)) { - console.warn('reRenderGroup: renderedGroup not found or not a group: ' + groupKey); - return; - } - const groupDef = this.findSurveyDefItem(groupKey); - if (!groupDef || !isSurveyGroupItem(groupDef)) { - console.warn('reRenderGroup: groupDef not found or not a group: ' + groupKey); - return; - } - - if (groupDef.selectionMethod && groupDef.selectionMethod.name === 'sequential') { - // simplified workflow: - this.sequentialRender(groupDef, renderedGroup, true); - return - } - - // Add items to the front - let currentIndex = 0; - let nextItem = this.getNextItem(groupDef, renderedGroup, renderedGroup.key, true); - while (nextItem !== null) { - if (!nextItem) { - break; - } - this.addRenderedItem(nextItem, renderedGroup, currentIndex); - if (isSurveyGroupItem(nextItem)) { - this.initRenderedGroup(nextItem, nextItem.key); - } - currentIndex += 1; - nextItem = this.getNextItem(groupDef, renderedGroup, nextItem.key, true); - } - - renderedGroup.items.forEach( - item => { - const itemDef = this.findSurveyDefItem(item.key); - // Remove item if condition not true - if (!itemDef || !this.evalConditions(itemDef.condition)) { - renderedGroup.items = removeItemByKey(renderedGroup.items, item.key); - // console.log('removed item: ' + item.key); - return; - } - - // Add direct follow ups - currentIndex = renderedGroup.items.findIndex(ci => ci.key === item.key); - if (currentIndex < 0) { - // console.warn('reRenderGroup: index to insert items not found'); - return; - } - - - if (isSurveyGroupItem(item)) { - // Re-Render groups recursively - this.reRenderGroup(item.key); - } else { - renderedGroup.items[currentIndex] = this.renderSingleSurveyItem(itemDef as SurveySingleItem, true); - } - - - let nextItem = this.getNextItem(groupDef, renderedGroup, item.key, true); - while (nextItem !== null) { - if (!nextItem) { - break; - } - currentIndex += 1; - this.addRenderedItem(nextItem, renderedGroup, currentIndex); - if (isSurveyGroupItem(nextItem)) { - this.initRenderedGroup(nextItem, nextItem.key); - } - nextItem = this.getNextItem(groupDef, renderedGroup, nextItem.key, true); - } - }); - - // Add items at the end if any - const lastItem = renderedGroup.items[renderedGroup.items.length - 1]; - nextItem = this.getNextItem(groupDef, renderedGroup, lastItem.key, false); - while (nextItem !== null) { - if (!nextItem) { - break; - } - this.addRenderedItem(nextItem, renderedGroup); - if (isSurveyGroupItem(nextItem)) { - this.initRenderedGroup(nextItem, nextItem.key); - } - nextItem = this.getNextItem(groupDef, renderedGroup, nextItem.key, false); - } - } - - private getNextItem(groupDef: SurveyGroupItem, parent: SurveyGroupItem, lastKey: string, onlyDirectFollower: boolean): SurveyItem | undefined { - // get unrendered question groups only - const availableItems = groupDef.items.filter(ai => { - return !parent.items.some(item => item.key === ai.key) && this.evalConditions(ai.condition); - }); - - if ((!lastKey || lastKey.length <= 0) && onlyDirectFollower) { - console.warn('getNextItem: missing input argument for lastKey'); - return; - } - const followUpItems = availableItems.filter(item => item.follows && item.follows.includes(lastKey)); - - if (followUpItems.length > 0) { - return SelectionMethod.pickAnItem(followUpItems, groupDef.selectionMethod); - } else if (onlyDirectFollower) { - return; - } - - const groupPool = availableItems.filter(item => !item.follows || item.follows.length < 1); - if (groupPool.length < 1) { - return; - } - - return SelectionMethod.pickAnItem(groupPool, groupDef.selectionMethod); - } - - private addRenderedItem(item: SurveyItem, parent: SurveyGroupItem, atPosition?: number): number { - let renderedItem: SurveyItem = { - ...item - }; - - if (isSurveyGroupItem(item)) { - (renderedItem as SurveyGroupItem).items = []; - } else { - renderedItem = this.renderSingleSurveyItem(item); - } - - if (atPosition === undefined || atPosition < 0) { - parent.items.push(renderedItem); - this.setTimestampFor('rendered', renderedItem.key); - return parent.items.length - 1; - } - parent.items.splice(atPosition, 0, renderedItem); - this.setTimestampFor('rendered', renderedItem.key); - return atPosition; - } - - private renderSingleSurveyItem(item: SurveySingleItem, rerender?: boolean): SurveySingleItem { - const renderedItem = { - ...item, - } - - if (item.validations) { - // question is not rendered yet, so to be able to handle validation using prefills, we need to add response extra: - const extraResponses: SurveyItemResponse[] = []; - const currentResponse = this.findResponseItem(item.key); - if (currentResponse) { - extraResponses.push(currentResponse); - } - - renderedItem.validations = item.validations.map(validation => { - return { - ...validation, - rule: this.evalConditions(validation.rule as Expression, undefined, extraResponses) - } - }); - } - - renderedItem.components = this.resolveComponentGroup(renderedItem, item.components, rerender); - - return renderedItem; - } - - private resolveComponentGroup(parentItem: SurveySingleItem, group?: ItemGroupComponent, rerender?: boolean): ItemGroupComponent { - if (!group) { - return { role: '', items: [] } - } - - if (!group.order || group.order.name === 'sequential') { - if (!group.items) { - console.warn(`this should not be a component group, items is missing or empty: ${parentItem.key} -> ${group.key}/${group.role} `); - return { - ...group, - content: this.resolveContent(group.content), - description: this.resolveContent(group.description), - disabled: isExpression(group.disabled) ? this.evalConditions(group.disabled as Expression, parentItem) : undefined, - displayCondition: group.displayCondition ? this.evalConditions(group.displayCondition as Expression, parentItem) : undefined, - } - } - return { - ...group, - content: this.resolveContent(group.content), - description: this.resolveContent(group.description), - disabled: isExpression(group.disabled) ? this.evalConditions(group.disabled as Expression, parentItem) : undefined, - displayCondition: group.displayCondition ? this.evalConditions(group.displayCondition as Expression, parentItem) : undefined, - items: group.items.map(comp => { - if (isItemGroupComponent(comp)) { - return this.resolveComponentGroup(parentItem, comp); - } - - return { - ...comp, - disabled: isExpression(comp.disabled) ? this.evalConditions(comp.disabled as Expression, parentItem) : undefined, - displayCondition: comp.displayCondition ? this.evalConditions(comp.displayCondition as Expression, parentItem) : undefined, - content: this.resolveContent(comp.content), - description: this.resolveContent(comp.description), - properties: this.resolveComponentProperties(comp.properties), - } - }), - } - } - if (rerender) { - console.error('define how to deal with rerendering - order should not change'); - } - console.error('order type not implemented: ', group.order.name); - return { - ...group - } - } - - private resolveContent(contents: LocalizedObject[] | undefined): LocalizedObject[] | undefined { - if (!contents) { return; } - - return contents.map(cont => { - if ((cont as LocalizedString).parts && (cont as LocalizedString).parts.length > 0) { - const resolvedContents = (cont as LocalizedString).parts.map( - p => { - if (typeof (p) === 'string' || typeof (p) === "number") { - // should not happen - only after resolved content is generated - return p - } - return p.dtype === 'exp' ? this.resolveExpression(p.exp) : p.str - } - ); - return { - code: cont.code, - parts: resolvedContents, - resolvedText: resolvedContents.join(''), - } - } - return { - ...cont - } - }) - } - - private resolveComponentProperties(props: ComponentProperties | undefined): ComponentProperties | undefined { - if (!props) { return; } - - const resolvedProps = { ...props }; - if (resolvedProps.min) { - const arg = expressionArgParser(resolvedProps.min as ExpressionArg); - resolvedProps.min = isExpression(arg) ? this.resolveExpression(arg) : arg; - } if (resolvedProps.max) { - const arg = expressionArgParser(resolvedProps.max as ExpressionArg); - resolvedProps.max = isExpression(arg) ? this.resolveExpression(arg) : arg; - } - if (resolvedProps.stepSize) { - const arg = expressionArgParser(resolvedProps.stepSize as ExpressionArg); - resolvedProps.stepSize = isExpression(arg) ? this.resolveExpression(arg) : arg; - } - if (resolvedProps.dateInputMode) { - const arg = expressionArgParser(resolvedProps.dateInputMode as ExpressionArg); - resolvedProps.dateInputMode = isExpression(arg) ? this.resolveExpression(arg) : arg; - } - return resolvedProps; - } - - private setTimestampFor(type: TimestampType, itemID: string, localeCode?: string) { - const obj = this.findResponseItem(itemID); - if (!obj) { - return; - } - if (!obj.meta) { - obj.meta = { ...initMeta }; - } - if (localeCode) { - obj.meta.localeCode = localeCode; - } - - const timestampLimit = 100; - - switch (type) { - case 'rendered': - obj.meta.rendered.push(Date.now()); - if (obj.meta.rendered.length > timestampLimit) { - obj.meta.rendered.splice(0, 1); - } - break; - case 'displayed': - obj.meta.displayed.push(Date.now()); - if (obj.meta.displayed.length > timestampLimit) { - obj.meta.displayed.splice(0, 1); - } - break; - case 'responded': - obj.meta.responded.push(Date.now()); - if (obj.meta.responded.length > timestampLimit) { - obj.meta.responded.splice(0, 1); - } - break; - } - } - - findSurveyDefItem(itemID: string): SurveyItem | undefined { - const ids = itemID.split('.'); - let obj: SurveyItem | undefined; - let compID = ''; - ids.forEach(id => { - if (compID === '') { - compID = id; - } else { - compID += '.' + id; - } - if (!obj) { - if (compID === this.surveyDef.surveyDefinition.key) { - obj = this.surveyDef.surveyDefinition; - } - return; - } - if (!isSurveyGroupItem(obj)) { - return; - } - const ind = obj.items.findIndex(item => item.key === compID); - if (ind < 0) { - if (this.showDebugMsg) { - console.warn('findSurveyDefItem: cannot find object for : ' + compID); - } - obj = undefined; - return; - } - obj = obj.items[ind]; - - }); - return obj; - } - - findRenderedItem(itemID: string): SurveyItem | undefined { - const ids = itemID.split('.'); - let obj: SurveyItem | undefined; - let compID = ''; - ids.forEach(id => { - if (compID === '') { - compID = id; - } else { - compID += '.' + id; - } - if (!obj) { - if (compID === this.renderedSurvey.key) { - obj = this.renderedSurvey; - } - return; - } - if (!isSurveyGroupItem(obj)) { - return; - } - const ind = obj.items.findIndex(item => item.key === compID); - if (ind < 0) { - if (this.showDebugMsg) { - console.warn('findRenderedItem: cannot find object for : ' + compID); - } - obj = undefined; - return; - } - obj = obj.items[ind]; - - }); - return obj; - } - - findResponseItem(itemID: string): SurveyItemResponse | undefined { - const ids = itemID.split('.'); - let obj: SurveyItemResponse | undefined; - let compID = ''; - ids.forEach(id => { - if (compID === '') { - compID = id; - } else { - compID += '.' + id; - } - if (!obj) { - if (compID === this.responses.key) { - obj = this.responses; - } - return; - } - if (!isSurveyGroupItemResponse(obj)) { - return; - } - const ind = obj.items.findIndex(item => item.key === compID); - if (ind < 0) { - // console.warn('findResponseItem: cannot find object for : ' + compID); - obj = undefined; - return; - } - obj = obj.items[ind]; - }); - return obj; - } - - resolveExpression(exp?: Expression, temporaryItem?: SurveySingleItem): any { - return this.evalEngine.eval( - exp, - this.renderedSurvey, - this.context, - this.responses, - temporaryItem, - this.showDebugMsg, - ); - } - - private getOnlyRenderedResponses(items: SurveyItemResponse[]): SurveyItemResponse[] { - const responses: SurveyItemResponse[] = []; - items.forEach(item => { - let currentItem: SurveyItemResponse = { - key: item.key, - meta: item.meta, - } - if (isSurveyGroupItemResponse(item)) { - (currentItem as SurveyGroupItemResponse).items = this.getOnlyRenderedResponses(item.items); - } else { - currentItem.response = item.response; - if (!this.findRenderedItem(item.key)) { - return; - } - } - responses.push(currentItem) - }) - return responses; - } - - evalConditions(condition?: Expression, temporaryItem?: SurveySingleItem, extraResponses?: SurveyItemResponse[]): boolean { - const extra = (extraResponses !== undefined) ? [...extraResponses] : []; - const responsesForRenderedItems: SurveyGroupItemResponse = { - ...this.responses, - items: [...this.getOnlyRenderedResponses(this.responses.items), ...extra] - } - - return this.evalEngine.eval( - condition, - this.renderedSurvey, - this.context, - responsesForRenderedItems, - temporaryItem, - this.showDebugMsg, - ); - } - -} diff --git a/src/engine/engine.ts b/src/engine/engine.ts new file mode 100644 index 0000000..61da34d --- /dev/null +++ b/src/engine/engine.ts @@ -0,0 +1,685 @@ +import { format, Locale } from 'date-fns'; +import { enUS } from 'date-fns/locale'; +import { shuffleIndices } from "../utils"; + +import { + Survey, + SurveyItemKey, + SurveyItemType, + SurveyItem, + QuestionItem, + GroupItem, + SurveyEndItem, + SingleChoiceQuestionItem, + ItemComponent, + MultipleChoiceQuestionItem, + ValueType, + ExpectedValueType, + initValueForType, +} from "../survey"; +import { JsonSurveyItemResponse, ResponseItem, ResponseMeta, SurveyItemResponse, TimestampType } from "../survey/responses"; +import { ExpressionEvaluator } from "../expressions/expression-evaluator"; +import { Expression, TemplateDefTypes, TemplateValueDefinition, TemplateValueFormatDate } from "../expressions"; +import { SurveyContext } from '../survey/utils/context'; + + +export type ScreenSize = "small" | "large"; + + +export interface RenderedSurveyItem { + key: SurveyItemKey; + type: SurveyItemType; + items?: Array + responseCompOrder?: Array; +} + +export class SurveyEngineCore { + private surveyDef: Survey; + private renderedSurveyTree: RenderedSurveyItem; + private context: SurveyContext; + private locale: string; + + private responses: { + [itemKey: string]: SurveyItemResponse; + }; + private prefills?: { + [itemKey: string]: SurveyItemResponse; + }; + private _openedAt: number; + private dateLocales: Array<{ code: string, locale: Locale }>; + + private cache!: { + validations: { + [itemKey: string]: { + [validationKey: string]: { + expression: Expression; + result: boolean; + }; + }; + }; + displayConditions: { + [itemKey: string]: { + root?: { + expression: Expression; + result: boolean; + }; + components?: { + [componentKey: string]: { + expression: Expression; + result: boolean; + }; + } + }; + }; + templateValues: { + [itemKey: string]: { + [templateValueKey: string]: { + value: ValueType; + templateDef: TemplateValueDefinition | undefined; + }; + }; + }; + disabledConditions: { + [itemKey: string]: { + components?: { + [componentKey: string]: { + expression: Expression; + result: boolean; + }; + } + }; + }; + } + + constructor( + survey: Survey, + context?: SurveyContext, + prefills?: JsonSurveyItemResponse[], + showDebugMsg?: boolean, + dateLocales?: Array<{ code: string, locale: Locale }>, + ) { + this._openedAt = Date.now(); + + + this.surveyDef = survey; + + this.context = context ? context : { + locale: 'en', + }; + this.locale = this.context.locale; + this.prefills = prefills ? prefills.reduce((acc, p) => { + acc[p.key] = SurveyItemResponse.fromJson(p); + return acc; + }, {} as { [itemKey: string]: SurveyItemResponse }) : undefined; + + this.dateLocales = dateLocales || [{ code: 'en', locale: enUS }]; + this.responses = this.initResponseObject(this.surveyDef.surveyItems); + + this.initCache(); + this.evalExpressions(); + + // init rendered survey + this.renderedSurveyTree = this.renderGroup(survey.rootItem); + } + + + // PUBLIC METHODS + setContext(context: SurveyContext) { + this.context = context; + } + + getDateLocales(): Array<{ code: string, locale: Locale }> { + return this.dateLocales.slice(); + } + + getCurrentDateLocale(): Locale | undefined { + const found = this.dateLocales.find(dl => dl.code === this.locale); + if (!found) { + console.warn(`Locale '${this.locale}' is not available. Using default locale.`); + if (this.dateLocales.length > 0) { + return this.dateLocales[0].locale; + } + return enUS; + } + return found?.locale; + } + + updateContext(context: SurveyContext) { + this.context = context; + this.locale = context.locale; + + // Re-render to update any locale-dependent expressions + this.evalExpressions(); + this.reRenderSurveyTree(); + } + + setResponse(targetKey: string, response?: ResponseItem) { + const target = this.getResponseItem(targetKey); + if (!target) { + throw new Error('setResponse: target not found for key: ' + targetKey); + } + + target.response = response; + this.setTimestampFor('responded', targetKey); + + this.evalExpressions(); + // re-render whole tree + this.reRenderSurveyTree(); + } + + get openedAt(): number { + return this._openedAt; + } + + get survey(): Readonly { + return this.surveyDef; + } + + getSurveyPages(size?: ScreenSize): RenderedSurveyItem[][] { + const renderedSurvey = flattenTree(this.renderedSurveyTree); + const pages = new Array(); + + if (!size) { + size = 'large'; + } + + let currentPage: RenderedSurveyItem[] = []; + + renderedSurvey.forEach(item => { + if (item.type === SurveyItemType.PageBreak) { + if (currentPage.length > 0) { + pages.push([...currentPage]); + currentPage = []; + } + return; + } + currentPage.push(item); + + if (!this.surveyDef.maxItemsPerPage) { + return; + } + let max = 0; + switch (size) { + case 'large': + max = this.surveyDef.maxItemsPerPage.large; + break; + case 'small': + max = this.surveyDef.maxItemsPerPage.small; + break + } + + if (currentPage.length >= max) { + pages.push([...currentPage]); + currentPage = []; + } + }); + if (currentPage.length > 0) { + pages.push([...currentPage]); + } + return pages; + } + + getRenderedSurveyItem(itemKey: string): RenderedSurveyItem | undefined { + const renderedSurvey = flattenTree(this.renderedSurveyTree); + return renderedSurvey.find(item => item.key.fullKey === itemKey); + } + + onQuestionDisplayed(itemKey: string) { + this.setTimestampFor('displayed', itemKey); + } + + + get surveyEndItem(): SurveyEndItem | undefined { + const renderedSurvey = flattenTree(this.renderedSurveyTree); + const firstRenderedSurveyEnd = renderedSurvey.find(item => item.type === SurveyItemType.SurveyEnd); + if (!firstRenderedSurveyEnd) { + return undefined; + } + return this.surveyDef.surveyItems[firstRenderedSurveyEnd.key.fullKey] as SurveyEndItem; + } + + getResponses(): SurveyItemResponse[] { + const renderedSurvey = flattenTree(this.renderedSurveyTree).filter( + item => item.type !== SurveyItemType.PageBreak && item.type !== SurveyItemType.SurveyEnd + ); + + const responses: SurveyItemResponse[] = []; + renderedSurvey.forEach((item, index) => { + const response = this.getResponseItem(item.key.fullKey); + if (!response) { + return; + } + if (!response.meta) { + response.meta = new ResponseMeta(); + } + response.meta.setPosition(index); + + const itemDef = this.surveyDef.surveyItems[item.key.fullKey]; + if (itemDef instanceof QuestionItem) { + response.confidentiality = itemDef.confidentiality; + } + + responses.push(response); + }); + return responses; + } + + getDisplayConditionValue(itemKey: string, componentKey?: string): boolean | undefined { + if (componentKey) { + return this.cache.displayConditions[itemKey]?.components?.[componentKey]?.result; + } + return this.cache.displayConditions[itemKey]?.root?.result; + } + + getDisabledConditionValue(itemKey: string, componentKey: string): boolean | undefined { + return this.cache.disabledConditions[itemKey]?.components?.[componentKey]?.result; + } + + getTemplateValue(itemKey: string, templateValueKey: string): { + value: ValueType; + templateDef: TemplateValueDefinition | undefined; + } | undefined { + return this.cache.templateValues[itemKey]?.[templateValueKey]; + } + + getValidationValues(itemKey: string): { + [validationKey: string]: boolean; + } | undefined { + const validations = this.cache.validations[itemKey]; + if (!validations) { + return undefined; + } + return Object.keys(validations).reduce((acc, validationKey) => { + acc[validationKey] = validations[validationKey].result; + return acc; + }, {} as { [validationKey: string]: boolean }); + } + + // INIT METHODS + + private initCache() { + this.cache = { + validations: {}, + displayConditions: {}, + templateValues: {}, + disabledConditions: {}, + } + + Object.keys(this.surveyDef.surveyItems).forEach(itemKey => { + // Init validations + const item = this.surveyDef.surveyItems[itemKey]; + if (item instanceof QuestionItem && item.validations && Object.keys(item.validations).length > 0) { + this.cache.validations[itemKey] = {}; + Object.keys(item.validations).forEach(validationKey => { + const valExp = item.validations![validationKey]; + if (!valExp) { + console.warn('initCache: validation expression not found: ' + itemKey + ' ' + validationKey); + return; + } + this.cache.validations[itemKey][validationKey] = { + expression: valExp, + result: false, + }; + }); + } + + // Init display conditions + if (item.displayConditions !== undefined && (item.displayConditions.root || item.displayConditions.components)) { + this.cache.displayConditions[itemKey] = {}; + if (item.displayConditions.root) { + this.cache.displayConditions[itemKey].root = { + expression: item.displayConditions.root, + result: false, + }; + } + if (item.displayConditions.components) { + this.cache.displayConditions[itemKey].components = {}; + Object.keys(item.displayConditions.components).forEach(componentKey => { + const compExp = item.displayConditions?.components?.[componentKey]; + if (!compExp) { + console.warn('initCache: display condition component expression not found: ' + itemKey + ' ' + componentKey); + return; + } + this.cache.displayConditions[itemKey].components![componentKey] = { + expression: compExp, + result: false, + }; + }); + } + } + + // Init disable conditions + if (item instanceof QuestionItem && item.disabledConditions !== undefined && item.disabledConditions.components !== undefined) { + this.cache.disabledConditions[itemKey] = { + components: {} + }; + Object.keys(item.disabledConditions.components).forEach(componentKey => { + const compExp = item.disabledConditions?.components?.[componentKey]; + if (!compExp) { + console.warn('initCache: disabled condition component expression not found: ' + itemKey + ' ' + componentKey); + return; + } + this.cache.disabledConditions[itemKey].components![componentKey] = { + expression: compExp, + result: false, + }; + }); + } + + // Init template values + if (item.templateValues) { + this.cache.templateValues[itemKey] = {}; + Object.keys(item.templateValues).forEach(templateValueKey => { + const templateDef = item.templateValues?.[templateValueKey]; + if (!templateDef) { + console.warn('initCache: template value not found: ' + itemKey + ' ' + templateValueKey); + return; + } + this.cache.templateValues[itemKey][templateValueKey] = { + value: initValueForType(item.templateValues?.[templateValueKey].returnType || ExpectedValueType.String), + templateDef: item.templateValues?.[templateValueKey], + }; + }); + } + }); + } + + + private initResponseObject(items: { + [itemKey: string]: SurveyItem + }): { + [itemKey: string]: SurveyItemResponse; + } { + const respGroup: { + [itemKey: string]: SurveyItemResponse; + } = {}; + + Object.entries(items).forEach(([itemKey, item]) => { + if ( + item.itemType === SurveyItemType.Group || + item.itemType === SurveyItemType.PageBreak || + item.itemType === SurveyItemType.SurveyEnd || + item.itemType === SurveyItemType.Display + ) { + return; + } else { + const prefill = this.prefills?.[itemKey]; + const applyPrefill = prefill && prefill.itemType === item.itemType; + respGroup[itemKey] = new SurveyItemResponse( + item, + applyPrefill ? prefill.response : undefined, + ) + } + }); + + return respGroup; + } + + private shouldRender(fullItemKey: string, fullComponentKey?: string): boolean { + const displayConditionResult = this.getDisplayConditionValue(fullItemKey, fullComponentKey); + if (displayConditionResult !== undefined) { + return displayConditionResult; + } + return true; + } + + private sequentialRender(groupDef: GroupItem): RenderedSurveyItem { + const newItems: RenderedSurveyItem[] = []; + + for (const fullItemKey of groupDef.items || []) { + const shouldRender = this.shouldRender(fullItemKey); + if (!shouldRender) { + continue; + } + + const itemDef = this.surveyDef.surveyItems[fullItemKey]; + if (!itemDef) { + console.warn('sequentialRender: item not found: ' + fullItemKey); + continue; + } + + if (itemDef.itemType === SurveyItemType.Group) { + newItems.push(this.renderGroup(itemDef as GroupItem)); + continue; + } + + newItems.push(this.renderItem(itemDef)); + } + + return { + key: groupDef.key, + type: SurveyItemType.Group, + items: newItems + }; + } + + private randomizedItemRender(groupDef: GroupItem, parent: RenderedSurveyItem): RenderedSurveyItem { + const newItems: RenderedSurveyItem[] = parent.items?.filter(rItem => + this.shouldRender(rItem.key.fullKey) + ) || []; + + + const itemKeys = groupDef.items || []; + const shuffledIndices = shuffleIndices(itemKeys.length); + + for (const index of shuffledIndices) { + const fullItemKey = itemKeys[index]; + const alreadyRenderedItem = parent.items?.find(rItem => rItem.key.fullKey === fullItemKey); + if (alreadyRenderedItem) { + continue; + } + + const shouldRender = this.shouldRender(fullItemKey); + if (!shouldRender) { + continue; + } + + const itemDef = this.surveyDef.surveyItems[fullItemKey]; + if (!itemDef) { + console.warn('randomizedItemRender: item not found: ' + fullItemKey); + continue; + } + + if (itemDef.itemType === SurveyItemType.Group) { + newItems.push(this.renderGroup(itemDef as GroupItem, parent)); + continue; + } + + newItems.push(this.renderItem(itemDef)); + } + + return { + key: groupDef.key, + type: SurveyItemType.Group, + items: newItems + }; + } + + private renderGroup(groupDef: GroupItem, parent?: RenderedSurveyItem): RenderedSurveyItem { + if (!parent) { + parent = { + key: groupDef.key, + type: SurveyItemType.Group, + items: [] + }; + } + + if (groupDef.shuffleItems) { + return this.randomizedItemRender(groupDef, parent); + } + + return this.sequentialRender(groupDef); + } + + private renderItem(itemDef: SurveyItem): RenderedSurveyItem { + let responseCompOrder: Array | undefined = undefined; + let responseCompOrderIndexes: Array | undefined = undefined; + switch (itemDef.itemType) { + case SurveyItemType.SingleChoiceQuestion: + responseCompOrder = []; + + if ((itemDef as SingleChoiceQuestionItem).responseConfig.shuffleItems) { + responseCompOrderIndexes = shuffleIndices((itemDef as SingleChoiceQuestionItem).responseConfig.items.length); + } else { + responseCompOrderIndexes = Array.from({ length: (itemDef as SingleChoiceQuestionItem).responseConfig.items.length }, (_, i) => i); + } + responseCompOrderIndexes.forEach(index => { + const option = (itemDef as SingleChoiceQuestionItem).responseConfig.items[index]; + if (this.shouldRender(option.key.parentItemKey.fullKey, option.key.fullKey)) { + responseCompOrder?.push(option); + } + }); + break; + case SurveyItemType.MultipleChoiceQuestion: + responseCompOrder = []; + if ((itemDef as MultipleChoiceQuestionItem).responseConfig.shuffleItems) { + responseCompOrderIndexes = shuffleIndices((itemDef as MultipleChoiceQuestionItem).responseConfig.items.length); + } else { + responseCompOrderIndexes = Array.from({ length: (itemDef as MultipleChoiceQuestionItem).responseConfig.items.length }, (_, i) => i); + } + responseCompOrderIndexes.forEach(index => { + const option = (itemDef as MultipleChoiceQuestionItem).responseConfig.items[index]; + if (this.shouldRender(option.key.parentItemKey.fullKey, option.key.fullKey)) { + responseCompOrder?.push(option); + } + }); + break; + default: + break; + } + + const renderedItem = { + key: itemDef.key, + type: itemDef.itemType, + responseCompOrder: responseCompOrder?.map(option => option.key.fullKey), + } + return renderedItem; + } + + private reRenderSurveyTree() { + this.renderedSurveyTree = this.renderGroup(this.surveyDef.rootItem); + } + + private setTimestampFor(type: TimestampType, itemID: string) { + const obj = this.getResponseItem(itemID); + if (!obj) { + return; + } + if (!obj.meta) { + obj.meta = new ResponseMeta(); + } + + obj.meta.addTimestamp(type, Date.now()); + } + + getResponseItem(itemFullKey: string): SurveyItemResponse | undefined { + return this.responses[itemFullKey]; + } + + private evalExpressions() { + const evalEngine = new ExpressionEvaluator( + { + responses: this.responses, + surveyContext: this.context, + } + ); + this.evalTemplateValues(evalEngine); + this.evalDisplayConditions(evalEngine); + this.evalDisableConditions(evalEngine); + this.evalValidations(evalEngine); + } + + + private evalTemplateValues(evalEngine: ExpressionEvaluator) { + Object.keys(this.cache.templateValues).forEach(itemKey => { + Object.keys(this.cache.templateValues[itemKey]).forEach(templateValueKey => { + const templateValue = this.cache.templateValues[itemKey][templateValueKey]; + if (!templateValue.templateDef?.expression) { + console.warn('evalTemplateValues: template value expression not found: ' + itemKey + '.' + templateValueKey); + return; + } + + let resolvedValue = evalEngine.eval(templateValue.templateDef.expression); + if (resolvedValue === undefined) { + console.warn('evalTemplateValues: template value expression returned undefined: ' + itemKey + '.' + templateValueKey); + return; + } + + switch (templateValue.templateDef.type) { + case TemplateDefTypes.Date2String: + resolvedValue = format(resolvedValue as Date, (templateValue.templateDef as TemplateValueFormatDate).dateFormat); + break; + } + this.cache.templateValues[itemKey][templateValueKey].value = resolvedValue; + }); + }); + } + + private evalDisplayConditions(evalEngine: ExpressionEvaluator) { + Object.keys(this.cache.displayConditions).forEach(itemKey => { + const displayCondition = this.cache.displayConditions[itemKey]; + if (displayCondition.root) { + const resolvedValue = evalEngine.eval(displayCondition.root.expression); + if (resolvedValue === undefined || typeof resolvedValue !== 'boolean') { + console.warn('evalDisplayConditions: display condition expression returned undefined: ' + itemKey); + return; + } + this.cache.displayConditions[itemKey].root!.result = resolvedValue; + } + if (displayCondition.components) { + Object.keys(displayCondition.components).forEach(componentKey => { + const resolvedValue = evalEngine.eval(displayCondition.components![componentKey].expression); + if (resolvedValue === undefined || typeof resolvedValue !== 'boolean') { + console.warn('evalDisplayConditions: display condition component expression returned undefined: ' + itemKey + '.' + componentKey); + return; + } + this.cache.displayConditions[itemKey].components![componentKey].result = resolvedValue; + }); + } + }); + } + + private evalDisableConditions(evalEngine: ExpressionEvaluator) { + Object.keys(this.cache.disabledConditions).forEach(itemKey => { + const disableCondition = this.cache.disabledConditions[itemKey]; + if (disableCondition.components) { + Object.keys(disableCondition.components).forEach(componentKey => { + const resolvedValue = evalEngine.eval(disableCondition.components![componentKey].expression); + if (resolvedValue === undefined || typeof resolvedValue !== 'boolean') { + console.warn('evalDisableConditions: disable condition component expression returned undefined: ' + itemKey + '.' + componentKey); + return; + } + this.cache.disabledConditions[itemKey].components![componentKey].result = resolvedValue; + }); + } + }); + } + + private evalValidations(evalEngine: ExpressionEvaluator) { + Object.keys(this.cache.validations).forEach(itemKey => { + const validation = this.cache.validations[itemKey]; + + Object.keys(validation).forEach(validationKey => { + const resolvedValue = evalEngine.eval(validation[validationKey].expression); + if (resolvedValue === undefined || typeof resolvedValue !== 'boolean') { + console.warn('evalValidations: validation expression returned undefined: ' + itemKey + '.' + validationKey); + return; + } + this.cache.validations[itemKey][validationKey].result = resolvedValue; + }); + }); + } +} + +export const flattenTree = (itemTree: RenderedSurveyItem): RenderedSurveyItem[] => { + const flatTree = new Array(); + + itemTree.items?.forEach(item => { + if (item.type === SurveyItemType.Group) { + flatTree.push(...flattenTree(item)); + } else { + flatTree.push({ ...item }); + } + }); + return flatTree; +} diff --git a/src/engine/index.ts b/src/engine/index.ts new file mode 100644 index 0000000..d3f4c46 --- /dev/null +++ b/src/engine/index.ts @@ -0,0 +1 @@ +export * from './engine'; \ No newline at end of file diff --git a/src/expression-eval.ts b/src/expression-eval.ts index db64df9..1cf94d5 100644 --- a/src/expression-eval.ts +++ b/src/expression-eval.ts @@ -1,7 +1,7 @@ -import { Expression, expressionArgParser, isExpression, ResponseItem, SurveyContext, SurveyGroupItem, SurveyGroupItemResponse, SurveyItem, SurveyItemResponse, SurveySingleItem, SurveyResponse, SurveySingleItemResponse } from "./data_types"; +/* import { Expression, expressionArgParser, isExpression, ResponseItem, SurveyContext, SurveyGroupItemResponse, SurveyItem, SurveyItemResponse, SurveySingleItem, SurveyResponse, SurveySingleItemResponse } from "./data_types"; import { fromUnixTime, differenceInSeconds, differenceInMinutes, differenceInHours, - differenceInDays, differenceInMonths, differenceInWeeks, differenceInYears, + differenceInMonths, differenceInWeeks, differenceInYears, } from 'date-fns'; export class ExpressionEval { @@ -668,9 +668,6 @@ export class ExpressionEval { return this.context.participantFlags[key]; } - /** - * Validate if a selected option has a value - return false if response is selected but no value is set, true otherwise - */ private validateSelectedOptionHasValueDefined(exp: Expression): boolean { if (!Array.isArray(exp.data) || exp.data.length !== 2) { this.logEvent('validateSelectedOptionHasValue: data attribute is missing or wrong: ' + exp.data); @@ -1158,3 +1155,4 @@ export class ExpressionEval { } } + */ \ No newline at end of file diff --git a/src/expressions/expression-evaluator.ts b/src/expressions/expression-evaluator.ts new file mode 100644 index 0000000..719e4b5 --- /dev/null +++ b/src/expressions/expression-evaluator.ts @@ -0,0 +1,440 @@ +import { + SurveyItemResponse, + ValueReferenceMethod, + ValueType, ExpectedValueType +} from "../survey"; +import { SurveyContext } from "../survey/utils/context"; +import { + ConstExpression, + ContextVariableExpression, + ContextVariableType, + Expression, + ExpressionType, + FunctionExpression, + FunctionExpressionNames, + ResponseVariableExpression, +} from "./expression"; + +export interface ExpressionContext { + surveyContext: SurveyContext; + responses: { + [key: string]: SurveyItemResponse; + } +} + +export class ExpressionEvaluator { + private context?: ExpressionContext; + + constructor(context?: ExpressionContext) { + this.context = context; + } + + eval(expression: Expression | undefined): ValueType | undefined { + if (expression === undefined) { + return undefined; + } + switch (expression.type) { + case ExpressionType.Const: + return this.evaluateConst(expression as ConstExpression); + case ExpressionType.Function: + return this.evaluateFunction(expression as FunctionExpression); + case ExpressionType.ResponseVariable: + return this.evaluateResponseVariable(expression as ResponseVariableExpression); + case ExpressionType.ContextVariable: + return this.evaluateContextVariable(expression as ContextVariableExpression); + default: + throw new Error(`Unsupported expression type: ${expression.type}`); + } + } + + setContext(context: ExpressionContext) { + this.context = context; + } + + private evaluateConst(expression: ConstExpression): ValueType | undefined { + return expression.value; + } + + private evaluateResponseVariable(expression: ResponseVariableExpression): ValueType | undefined { + const varRef = expression.responseVariableRef; + + switch (varRef.name) { + case ValueReferenceMethod.get: + return this.context?.responses[varRef.itemKey.fullKey]?.response?.get(varRef._slotKey?.fullKey); + case ValueReferenceMethod.isDefined: + return this.context?.responses[varRef.itemKey.fullKey]?.response?.get(varRef._slotKey?.fullKey) !== undefined; + default: + throw new Error(`Unsupported value reference method: ${varRef.name}`); + } + } + + private evaluateFunction(expression: FunctionExpression): ValueType | undefined { + switch (expression.functionName) { + case FunctionExpressionNames.and: + return this.evaluateAnd(expression); + case FunctionExpressionNames.or: + return this.evaluateOr(expression); + case FunctionExpressionNames.not: + return this.evaluateNot(expression); + // string methods: + case FunctionExpressionNames.str_eq: + return this.evaluateStrEq(expression); + // numeric methods: + case FunctionExpressionNames.eq: + return this.evaluateEq(expression); + case FunctionExpressionNames.gt: + return this.evaluateGt(expression); + case FunctionExpressionNames.gte: + return this.evaluateGte(expression); + case FunctionExpressionNames.lt: + return this.evaluateLt(expression); + case FunctionExpressionNames.lte: + return this.evaluateLte(expression); + case FunctionExpressionNames.in_range: + return this.evaluateInRange(expression); + case FunctionExpressionNames.sum: + return this.evaluateSum(expression); + case FunctionExpressionNames.min: + return this.evaluateMin(expression); + case FunctionExpressionNames.max: + return this.evaluateMax(expression); + // list methods: + case FunctionExpressionNames.list_contains: + return this.evaluateListContains(expression); + + default: + throw new Error(`Unsupported function: ${expression.functionName}`); + } + // TODO: implement function evaluation + return undefined; + } + + private evaluateContextVariable(expression: ContextVariableExpression): ValueType | undefined { + if (!this.context?.surveyContext) { + return undefined; + } + + const surveyContext = this.context.surveyContext; + + switch (expression.contextType) { + case ContextVariableType.Locale: + return surveyContext.locale; + + case ContextVariableType.ParticipantFlag: { + if (!expression.key) { + return undefined; + } + + const flagKey = this.eval(expression.key); + if (typeof flagKey !== 'string') { + return undefined; + } + + const flagValue = surveyContext.participantFlags?.[flagKey]; + + // Handle different return types for participant flags + switch (expression.asType) { + case ExpectedValueType.Boolean: + // exists? + return flagValue !== undefined; + case ExpectedValueType.String: + return flagValue; + case ExpectedValueType.Number: { + if (flagValue === undefined) { + return undefined; + } + const numValue = parseFloat(flagValue); + return isNaN(numValue) ? undefined : numValue; + } + case ExpectedValueType.Date: { + if (flagValue === undefined) { + return undefined; + } + const timestamp = parseFloat(flagValue); + return isNaN(timestamp) ? undefined : new Date(timestamp * 1000); + } + default: + return flagValue; + } + } + + case ContextVariableType.CustomValue: { + if (!expression.key) { + return undefined; + } + + const customKey = this.eval(expression.key); + if (typeof customKey !== 'string') { + return undefined; + } + + const customValue = surveyContext.customValues?.[customKey]; + if (customValue === undefined) { + return undefined; + } + + // Type checking for custom values + switch (expression.asType) { + case ExpectedValueType.String: + return typeof customValue === 'string' ? customValue : undefined; + case ExpectedValueType.Number: + return typeof customValue === 'number' ? customValue : undefined; + case ExpectedValueType.Boolean: + return typeof customValue === 'boolean' ? customValue : undefined; + case ExpectedValueType.Date: + return customValue instanceof Date ? customValue : undefined; + case ExpectedValueType.StringArray: + return Array.isArray(customValue) && customValue.every(v => typeof v === 'string') ? customValue : undefined; + case ExpectedValueType.NumberArray: + return Array.isArray(customValue) && customValue.every(v => typeof v === 'number') ? customValue : undefined; + case ExpectedValueType.DateArray: + return Array.isArray(customValue) && customValue.every(v => v instanceof Date) ? customValue : undefined; + default: + return customValue; + } + } + + case ContextVariableType.CustomExpression: { + if (!expression.key) { + return undefined; + } + + const expressionKey = this.eval(expression.key); + if (typeof expressionKey !== 'string') { + return undefined; + } + + const customExpression = surveyContext.customExpressions?.[expressionKey]; + if (typeof customExpression !== 'function') { + return undefined; + } + + try { + const args = expression.arguments; + const result = customExpression(args); + + // Type checking for custom expression results + switch (expression.asType) { + case ExpectedValueType.String: + return typeof result === 'string' ? result : undefined; + case ExpectedValueType.Number: + return typeof result === 'number' ? result : undefined; + case ExpectedValueType.Boolean: + return typeof result === 'boolean' ? result : undefined; + case ExpectedValueType.Date: + return result instanceof Date ? result : undefined; + case ExpectedValueType.StringArray: + return Array.isArray(result) && result.every(v => typeof v === 'string') ? result : undefined; + case ExpectedValueType.NumberArray: + return Array.isArray(result) && result.every(v => typeof v === 'number') ? result : undefined; + case ExpectedValueType.DateArray: + return Array.isArray(result) && result.every(v => v instanceof Date) ? result : undefined; + default: + return result; + } + } catch (_error) { + return undefined; + } + } + + default: + return undefined; + } + } + + // ---------------- FUNCTIONS ---------------- + + private evaluateAnd(expression: FunctionExpression): boolean { + return expression.arguments.every(arg => this.eval(arg) === true); + } + + private evaluateOr(expression: FunctionExpression): boolean { + return expression.arguments.some(arg => this.eval(arg) === true); + } + + private evaluateNot(expression: FunctionExpression): boolean { + if (expression.arguments.length !== 1 || expression.arguments[0] === undefined) { + throw new Error(`Not function expects 1 argument, got ${expression.arguments.length}`); + } + const resolvedValue = this.eval(expression.arguments[0]); + if (resolvedValue === undefined || typeof resolvedValue !== 'boolean') { + return false; + } + return !resolvedValue; + } + + private evaluateListContains(expression: FunctionExpression): boolean { + if (expression.arguments.length !== 2) { + throw new Error(`List contains function expects 2 arguments, got ${expression.arguments.length}`); + } + const resolvedList = this.eval(expression.arguments[0]); + const resolvedItem = this.eval(expression.arguments[1]); + + if (resolvedList === undefined || resolvedItem === undefined) { + return false; + } + + const list = resolvedList as string[]; + const item = resolvedItem as string; + + return list.includes(item) ? true : false; + } + + private evaluateStrEq(expression: FunctionExpression): boolean { + if (expression.arguments.length !== 2) { + throw new Error(`String equals function expects 2 arguments, got ${expression.arguments.length}`); + } + const resolvedA = this.eval(expression.arguments[0]); + const resolvedB = this.eval(expression.arguments[1]); + + if (resolvedA === undefined || resolvedB === undefined) { + return false; + } + + return resolvedA === resolvedB; + } + + private evaluateEq(expression: FunctionExpression): boolean { + if (expression.arguments.length !== 2) { + throw new Error(`Equals function expects 2 arguments, got ${expression.arguments.length}`); + } + const resolvedA = this.eval(expression.arguments[0]); + const resolvedB = this.eval(expression.arguments[1]); + + if (resolvedA === undefined || resolvedB === undefined) { + return false; + } + + return resolvedA === resolvedB; + } + + private evaluateGt(expression: FunctionExpression): boolean { + if (expression.arguments.length !== 2) { + throw new Error(`Greater than function expects 2 arguments, got ${expression.arguments.length}`); + } + const resolvedA = this.eval(expression.arguments[0]); + const resolvedB = this.eval(expression.arguments[1]); + + if (resolvedA === undefined || resolvedB === undefined) { + return false; + } + + return resolvedA > resolvedB; + } + + private evaluateGte(expression: FunctionExpression): boolean { + if (expression.arguments.length !== 2) { + throw new Error(`Greater than or equal to function expects 2 arguments, got ${expression.arguments.length}`); + } + const resolvedA = this.eval(expression.arguments[0]); + const resolvedB = this.eval(expression.arguments[1]); + + if (resolvedA === undefined || resolvedB === undefined) { + return false; + } + + return resolvedA >= resolvedB; + } + + private evaluateLt(expression: FunctionExpression): boolean { + if (expression.arguments.length !== 2) { + throw new Error(`Less than function expects 2 arguments, got ${expression.arguments.length}`); + } + const resolvedA = this.eval(expression.arguments[0]); + const resolvedB = this.eval(expression.arguments[1]); + + if (resolvedA === undefined || resolvedB === undefined) { + return false; + } + + return resolvedA < resolvedB; + } + + private evaluateLte(expression: FunctionExpression): boolean { + if (expression.arguments.length !== 2) { + throw new Error(`Less than or equal to function expects 2 arguments, got ${expression.arguments.length}`); + } + const resolvedA = this.eval(expression.arguments[0]); + const resolvedB = this.eval(expression.arguments[1]); + + if (resolvedA === undefined || resolvedB === undefined) { + return false; + } + + return resolvedA <= resolvedB; + } + + private evaluateInRange(expression: FunctionExpression): boolean { + if (expression.arguments.length !== 4) { + throw new Error(`In range function expects 4 arguments, got ${expression.arguments.length}`); + } + const resolvedValue = this.eval(expression.arguments[0]); + const resolvedMin = this.eval(expression.arguments[1]); + const resolvedMax = this.eval(expression.arguments[2]); + const resolvedInclusive = this.eval(expression.arguments[3]) === true + + if (resolvedValue === undefined || resolvedMin === undefined || resolvedMax === undefined) { + return false; + } + + return resolvedInclusive ? resolvedValue >= resolvedMin && resolvedValue <= resolvedMax : resolvedValue > resolvedMin && resolvedValue < resolvedMax; + } + + private evaluateSum(expression: FunctionExpression): number { + if (expression.arguments.length < 1) { + throw new Error(`Sum function expects at least 1 argument, got ${expression.arguments.length}`); + } + return expression.arguments.reduce((sum, arg) => { + if (arg === undefined) { + return sum; + } + const resolvedValue = this.eval(arg); + if (resolvedValue === undefined) { + return sum; + } + if (typeof resolvedValue === 'number') { + return sum + resolvedValue; + } + throw new Error(`Sum function expects all arguments to be numbers, got ${typeof resolvedValue}`); + }, 0); + } + + private evaluateMin(expression: FunctionExpression): number { + if (expression.arguments.length < 1) { + throw new Error(`Min function expects at least 1 argument, got ${expression.arguments.length}`); + } + return expression.arguments.reduce((min, arg) => { + if (arg === undefined) { + return min; + } + const resolvedValue = this.eval(arg); + if (resolvedValue === undefined) { + return min; + } + if (typeof resolvedValue === 'number') { + return Math.min(min, resolvedValue); + } + throw new Error(`Min function expects all arguments to be numbers, got ${typeof resolvedValue}`); + }, Infinity); + } + + private evaluateMax(expression: FunctionExpression): number { + if (expression.arguments.length < 1) { + throw new Error(`Max function expects at least 1 argument, got ${expression.arguments.length}`); + } + return expression.arguments.reduce((max, arg) => { + if (arg === undefined) { + return max; + } + const resolvedValue = this.eval(arg); + if (resolvedValue === undefined) { + return max; + } + if (typeof resolvedValue === 'number') { + return Math.max(max, resolvedValue); + } + throw new Error(`Max function expects all arguments to be numbers, got ${typeof resolvedValue}`); + }, -Infinity); + } + +} diff --git a/src/expressions/expression.ts b/src/expressions/expression.ts new file mode 100644 index 0000000..b7cb5fe --- /dev/null +++ b/src/expressions/expression.ts @@ -0,0 +1,331 @@ +import { ValueReference } from "../survey/utils/value-reference"; +import { ExpectedValueType, ValueType } from "../survey/utils/types"; +import { SurveyItemKey } from "../survey/item-component-key"; + + +export enum ExpressionType { + Const = 'const', + ResponseVariable = 'responseVariable', + ContextVariable = 'contextVariable', + Function = 'function', +} + +export enum ContextVariableType { + Locale = 'locale', + ParticipantFlag = 'participantFlag', + CustomValue = 'customValue', + CustomExpression = 'customExpression' +} + +export interface ExpressionEditorConfig { + usedTemplate?: string; +} + +export interface JsonConstExpression { + type: ExpressionType.Const; + value?: ValueType; + + editorConfig?: ExpressionEditorConfig; +} + +export interface JsonResponseVariableExpression { + type: ExpressionType.ResponseVariable; + variableRef: string; + + editorConfig?: ExpressionEditorConfig; +} + +export interface JsonContextVariableExpression { + type: ExpressionType.ContextVariable; + + contextType: ContextVariableType; + key?: JsonExpression; + arguments?: Array; + asType?: ExpectedValueType; + + editorConfig?: ExpressionEditorConfig; +} + +export interface JsonFunctionExpression { + type: ExpressionType.Function; + functionName: string; + arguments: Array; + + editorConfig?: ExpressionEditorConfig; +} + +export type JsonExpression = JsonConstExpression | JsonResponseVariableExpression | JsonContextVariableExpression | JsonFunctionExpression; + + + +/** + * Base class for all expressions. + */ +export abstract class Expression { + type: ExpressionType; + editorConfig?: ExpressionEditorConfig; + + constructor(type: ExpressionType, editorConfig?: ExpressionEditorConfig) { + this.type = type; + this.editorConfig = editorConfig; + } + + static fromJson(json: JsonExpression | undefined): Expression | undefined { + if (!json) { + return undefined; + } + + switch (json.type) { + case ExpressionType.Const: + return ConstExpression.fromJson(json); + case ExpressionType.ResponseVariable: + return ResponseVariableExpression.fromJson(json); + case ExpressionType.ContextVariable: + return ContextVariableExpression.fromJson(json); + case ExpressionType.Function: + return FunctionExpression.fromJson(json); + } + } + + /** + * Returns all unique response variable references in the expression. + * @returns A list of ValueReference objects. + */ + abstract get responseVariableRefs(): ValueReference[] + abstract toJson(): JsonExpression | undefined; + + clone(): Expression { + return Expression.fromJson(this.toJson()) ?? (() => { + throw new Error('Failed to clone expression'); + })(); + } + + /** + * Updates references to the item key in the expression. + * @param oldItemKey The old item key to replace + * @param newItemKey The new item key to replace with + * @returns True if any references were updated, false otherwise + */ + abstract updateItemKeyReferences(oldItemKey: string, newItemKey: string): boolean; +} + +export class ConstExpression extends Expression { + type!: ExpressionType.Const; + value?: ValueType; + + constructor(value?: ValueType, editorConfig?: ExpressionEditorConfig) { + super(ExpressionType.Const, editorConfig); + this.value = value; + this.type = ExpressionType.Const; + } + + static fromJson(json: JsonExpression): ConstExpression { + if (json.type !== ExpressionType.Const) { + throw new Error('Invalid expression type: ' + json.type); + } + + return new ConstExpression(json.value, json.editorConfig); + } + + get responseVariableRefs(): ValueReference[] { + return []; + } + + toJson(): JsonExpression { + return { + type: this.type, + value: this.value, + editorConfig: this.editorConfig + } + } + + updateItemKeyReferences(_oldItemKey: string, _newItemKey: string): boolean { + // Const expressions don't have item references + return false; + } +} + +export class ResponseVariableExpression extends Expression { + type: ExpressionType.ResponseVariable; + variableRef: string; + + constructor(variableRef: string, editorConfig?: ExpressionEditorConfig) { + super(ExpressionType.ResponseVariable, editorConfig); + this.variableRef = variableRef; + this.type = ExpressionType.ResponseVariable; + } + + static fromJson(json: JsonExpression): ResponseVariableExpression { + if (json.type !== ExpressionType.ResponseVariable) { + throw new Error('Invalid expression type: ' + json.type); + } + + return new ResponseVariableExpression(json.variableRef, json.editorConfig); + } + + get responseVariableRefs(): ValueReference[] { + return [new ValueReference(this.variableRef)]; + } + + get responseVariableRef(): ValueReference { + return new ValueReference(this.variableRef); + } + + toJson(): JsonExpression { + return { + type: this.type, + variableRef: this.variableRef, + editorConfig: this.editorConfig + } + } + + updateItemKeyReferences(oldItemKey: string, newItemKey: string): boolean { + const valueRef = new ValueReference(this.variableRef); + if (valueRef.itemKey.fullKey === oldItemKey) { + valueRef.itemKey = SurveyItemKey.fromFullKey(newItemKey); + this.variableRef = valueRef.toString(); + return true; + } + return false; + } +} + +export class ContextVariableExpression extends Expression { + type: ExpressionType.ContextVariable; + + contextType: ContextVariableType; + key?: Expression; + arguments?: Array; + asType?: ExpectedValueType; + + constructor(contextType: ContextVariableType, key?: Expression, args?: Array, asType?: ExpectedValueType, editorConfig?: ExpressionEditorConfig) { + super(ExpressionType.ContextVariable, editorConfig); + this.type = ExpressionType.ContextVariable; + this.contextType = contextType; + this.key = key; + this.arguments = args; + this.asType = asType; + } + + static fromJson(json: JsonExpression): ContextVariableExpression { + if (json.type !== ExpressionType.ContextVariable) { + throw new Error('Invalid expression type: ' + json.type); + } + + return new ContextVariableExpression(json.contextType, Expression.fromJson(json.key), json.arguments?.map(arg => Expression.fromJson(arg)), json.asType, json.editorConfig); + } + + get responseVariableRefs(): ValueReference[] { + return this.arguments?.flatMap(arg => arg?.responseVariableRefs).filter(ref => ref !== undefined) ?? []; + } + + toJson(): JsonExpression { + return { + type: this.type, + contextType: this.contextType, + key: this.key?.toJson(), + arguments: this.arguments?.map(arg => arg?.toJson()), + asType: this.asType, + editorConfig: this.editorConfig + } + } + + updateItemKeyReferences(oldItemKey: string, newItemKey: string): boolean { + let updated = false; + if (this.key) { + updated = this.key.updateItemKeyReferences(oldItemKey, newItemKey) || updated; + } + if (this.arguments) { + for (const arg of this.arguments) { + if (arg) { + updated = arg.updateItemKeyReferences(oldItemKey, newItemKey) || updated; + } + } + } + return updated; + } +} + + +export enum FunctionExpressionNames { + and = 'and', + or = 'or', + not = 'not', + + list_contains = 'list_contains', + + // numeric functions + eq = 'eq', + gt = 'gt', + gte = 'gte', + lt = 'lt', + lte = 'lte', + in_range = 'in_range', + + sum = 'sum', + min = 'min', + max = 'max', + + + + + // string functions + str_eq = 'str_eq', + + // date functions + date_eq = 'date_eq', +} + +export class FunctionExpression extends Expression { + type: ExpressionType.Function; + functionName: FunctionExpressionNames; + arguments: Array; + + constructor(functionName: FunctionExpressionNames, args: Array, editorConfig?: ExpressionEditorConfig) { + super(ExpressionType.Function); + this.type = ExpressionType.Function; + this.functionName = functionName; + this.arguments = args; + this.editorConfig = editorConfig; + } + + static fromJson(json: JsonExpression): FunctionExpression { + if (json.type !== ExpressionType.Function) { + throw new Error('Invalid expression type: ' + json.type); + } + + const functionName = json.functionName as FunctionExpressionNames; + if (!Object.values(FunctionExpressionNames).includes(functionName)) { + throw new Error('Invalid function name: ' + functionName); + } + + const expr = new FunctionExpression(functionName, json.arguments.map(arg => Expression.fromJson(arg))); + expr.editorConfig = json.editorConfig; + return expr; + } + + get responseVariableRefs(): ValueReference[] { + const refs = this.arguments.flatMap(arg => arg?.responseVariableRefs).filter(ref => ref !== undefined); + const refStrings = refs.map(ref => ref.toString()); + return [...new Set(refStrings)].map(ref => new ValueReference(ref)); + } + + toJson(): JsonExpression | undefined { + return { + type: this.type, + functionName: this.functionName, + arguments: this.arguments.map(arg => arg?.toJson()), + editorConfig: this.editorConfig + } + } + + updateItemKeyReferences(oldItemKey: string, newItemKey: string): boolean { + let updated = false; + for (const arg of this.arguments) { + if (arg) { + updated = arg.updateItemKeyReferences(oldItemKey, newItemKey) || updated; + } + } + return updated; + } +} diff --git a/src/expressions/index.ts b/src/expressions/index.ts new file mode 100644 index 0000000..d200f0d --- /dev/null +++ b/src/expressions/index.ts @@ -0,0 +1,3 @@ +export * from './template-value'; +export * from './expression'; +export * from './expression-evaluator'; diff --git a/src/expressions/template-value.ts b/src/expressions/template-value.ts new file mode 100644 index 0000000..838b646 --- /dev/null +++ b/src/expressions/template-value.ts @@ -0,0 +1,62 @@ +import { ExpectedValueType } from "../survey"; +import { Expression, JsonExpression } from "./expression"; + + +export enum TemplateDefTypes { + Default = 'default', + Date2String = 'date2string' +} + +export type TemplateValueBase = { + type: TemplateDefTypes; + returnType: ExpectedValueType; + expression?: Expression; +} + + +export type TemplateValueFormatDate = TemplateValueBase & { + type: TemplateDefTypes.Date2String; + returnType: ExpectedValueType.String; + dateFormat: string; +} + +export type TemplateValueDefinition = TemplateValueBase | TemplateValueFormatDate; + + + +export const templateValueToJson = (templateValue: TemplateValueDefinition): JsonTemplateValue => { + const json: JsonTemplateValue = { + type: templateValue.type, + returnType: templateValue.returnType, + expression: templateValue.expression?.toJson(), + } + if (templateValue.type === TemplateDefTypes.Date2String) { + json.dateFormat = (templateValue as TemplateValueFormatDate).dateFormat; + } + return json; +} + +export const templateValueFromJson = (json: JsonTemplateValue): TemplateValueDefinition => { + return { + type: json.type, + expression: json.expression ? Expression.fromJson(json.expression) : undefined, + returnType: json.returnType, + dateFormat: json.dateFormat + } +} + +export const templateValuesToJson = (templateValues: { [templateValueKey: string]: TemplateValueDefinition }): { [templateValueKey: string]: JsonTemplateValue } => { + return Object.fromEntries(Object.entries(templateValues).map(([key, value]) => [key, templateValueToJson(value)])); +} + +export const templateValuesFromJson = (json: { [templateValueKey: string]: JsonTemplateValue }): { [templateValueKey: string]: TemplateValueDefinition } => { + return Object.fromEntries(Object.entries(json).map(([key, value]) => [key, templateValueFromJson(value)])); +} + +export interface JsonTemplateValue { + type: TemplateDefTypes; + expression?: JsonExpression; + returnType: ExpectedValueType; + dateFormat?: string; +} + diff --git a/src/index.ts b/src/index.ts index e09e99a..530dc4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ -export const SurveyEngineTest = () => { - console.log('test init project'); -}; +// TODO: Remove this once we have a proper export structure +export * from './data_types'; + +export * from './engine'; +export * from './expressions'; +export * from './utils'; +export * from './survey'; -export const OpTest = (a: number, b: number): number => { - return a + b; -} \ No newline at end of file diff --git a/src/legacy-conversion.ts b/src/legacy-conversion.ts new file mode 100644 index 0000000..bae5b47 --- /dev/null +++ b/src/legacy-conversion.ts @@ -0,0 +1,534 @@ +/* TODO: import { + LegacySurvey, + LegacySurveyItem, + LegacySurveyGroupItem, + LegacySurveySingleItem, + LegacyItemComponent, + LegacyItemGroupComponent, + LegacyResponseComponent, + LegacyLocalizedObject, + LegacySurveyProps, + LegacyValidation, + LegacyConfidentialMode, + isLegacySurveyGroupItem, + isLegacyItemGroupComponent +} from './data_types/legacy-types'; + +import { + Survey, + SurveyItem, + SurveyGroupItem, + SurveySingleItem, + ItemComponent, + ItemGroupComponent, + ResponseComponent, + LocalizedContent, + SurveyProps, + Validation, + ConfidentialMode, + isSurveyGroupItem, + isItemGroupComponent +} from './data_types'; + +import { ExpressionArgDType } from './data_types/expression'; + + +export function convertLegacyToNewSurvey(legacySurvey: LegacySurvey): Survey { + const newSurvey: Survey = { + schemaVersion: 1, + versionId: legacySurvey.versionId, + surveyDefinition: convertLegacySurveyItem(legacySurvey.surveyDefinition) as SurveyGroupItem, + }; + + // Copy optional properties + if (legacySurvey.id !== undefined) newSurvey.id = legacySurvey.id; + if (legacySurvey.props) newSurvey.props = convertLegacySurveyProps(legacySurvey.props); + if (legacySurvey.prefillRules) newSurvey.prefillRules = legacySurvey.prefillRules; + if (legacySurvey.contextRules) newSurvey.contextRules = legacySurvey.contextRules; + if (legacySurvey.maxItemsPerPage) newSurvey.maxItemsPerPage = legacySurvey.maxItemsPerPage; + if (legacySurvey.availableFor) newSurvey.availableFor = legacySurvey.availableFor; + if (legacySurvey.requireLoginBeforeSubmission !== undefined) { + newSurvey.requireLoginBeforeSubmission = legacySurvey.requireLoginBeforeSubmission; + } + if (legacySurvey.published) newSurvey.published = legacySurvey.published; + if (legacySurvey.unpublished) newSurvey.unpublished = legacySurvey.unpublished; + if (legacySurvey.metadata) newSurvey.metadata = legacySurvey.metadata; + + return newSurvey; +} + +export function convertNewToLegacySurvey(survey: Survey): LegacySurvey { + const legacySurvey: LegacySurvey = { + versionId: survey.versionId, + surveyDefinition: convertSurveyItemToLegacy(survey.surveyDefinition) as LegacySurveyGroupItem, + }; + + // Copy optional properties + if (survey.id !== undefined) legacySurvey.id = survey.id; + if (survey.props) legacySurvey.props = convertSurveyPropsToLegacy(survey.props); + if (survey.prefillRules) legacySurvey.prefillRules = survey.prefillRules; + if (survey.contextRules) legacySurvey.contextRules = survey.contextRules; + if (survey.maxItemsPerPage) legacySurvey.maxItemsPerPage = survey.maxItemsPerPage; + if (survey.availableFor) legacySurvey.availableFor = survey.availableFor; + if (survey.requireLoginBeforeSubmission !== undefined) { + legacySurvey.requireLoginBeforeSubmission = survey.requireLoginBeforeSubmission; + } + if (survey.published) legacySurvey.published = survey.published; + if (survey.unpublished) legacySurvey.unpublished = survey.unpublished; + if (survey.metadata) legacySurvey.metadata = survey.metadata; + + return legacySurvey; +} + +// Helper functions for converting survey items +function convertLegacySurveyItem(legacyItem: LegacySurveyItem): SurveyItem { + if (isLegacySurveyGroupItem(legacyItem)) { + return convertLegacySurveyGroupItem(legacyItem); + } else { + return convertLegacySurveySingleItem(legacyItem); + } +} + +function convertSurveyItemToLegacy(item: SurveyItem): LegacySurveyItem { + if (isSurveyGroupItem(item)) { + return convertSurveyGroupItemToLegacy(item); + } else { + return convertSurveySingleItemToLegacy(item); + } +} + +function convertLegacySurveyGroupItem(legacyGroup: LegacySurveyGroupItem): SurveyGroupItem { + return { + key: legacyGroup.key, + items: legacyGroup.items.map(convertLegacySurveyItem), + metadata: legacyGroup.metadata, + follows: legacyGroup.follows, + condition: legacyGroup.condition, + priority: legacyGroup.priority, + selectionMethod: legacyGroup.selectionMethod, + }; +} + +function convertSurveyGroupItemToLegacy(group: SurveyGroupItem): LegacySurveyGroupItem { + return { + key: group.key, + items: group.items.map(convertSurveyItemToLegacy), + metadata: group.metadata, + follows: group.follows, + condition: group.condition, + priority: group.priority, + selectionMethod: group.selectionMethod, + }; +} + +function convertLegacySurveySingleItem(legacyItem: LegacySurveySingleItem): SurveySingleItem { + const newItem: SurveySingleItem = { + key: legacyItem.key, + metadata: legacyItem.metadata, + follows: legacyItem.follows, + condition: legacyItem.condition, + priority: legacyItem.priority, + type: legacyItem.type, + mapToKey: legacyItem.mapToKey, + }; + + if (legacyItem.components) { + newItem.components = convertLegacyItemGroupComponent(legacyItem.components); + } + + if (legacyItem.validations) { + newItem.validations = legacyItem.validations.map(convertLegacyValidation); + } + + if (legacyItem.confidentialMode) { + newItem.confidentialMode = legacyItem.confidentialMode as ConfidentialMode; + } + + return newItem; +} + +function convertSurveySingleItemToLegacy(item: SurveySingleItem): LegacySurveySingleItem { + const legacyItem: LegacySurveySingleItem = { + key: item.key, + metadata: item.metadata, + follows: item.follows, + condition: item.condition, + priority: item.priority, + type: item.type, + mapToKey: item.mapToKey, + }; + + if (item.components) { + legacyItem.components = convertItemGroupComponentToLegacy(item.components); + } + + if (item.validations) { + legacyItem.validations = item.validations.map(convertValidationToLegacy); + } + + if (item.confidentialMode) { + legacyItem.confidentialMode = item.confidentialMode as LegacyConfidentialMode; + } + + return legacyItem; +} + +// Helper functions for converting components +function convertLegacyItemComponent(legacyComponent: LegacyItemComponent): ItemComponent { + if (isLegacyItemGroupComponent(legacyComponent)) { + return convertLegacyItemGroupComponent(legacyComponent); + } else { + return convertLegacyResponseComponent(legacyComponent as LegacyResponseComponent); + } +} + +function convertItemComponentToLegacy(component: ItemComponent): LegacyItemComponent { + if (isItemGroupComponent(component)) { + return convertItemGroupComponentToLegacy(component); + } else { + return convertResponseComponentToLegacy(component as ResponseComponent); + } +} + +function convertLegacyItemGroupComponent(legacyComponent: LegacyItemGroupComponent): ItemGroupComponent { + const newComponent: ItemGroupComponent = { + role: legacyComponent.role, + key: legacyComponent.key, + displayCondition: legacyComponent.displayCondition, + disabled: legacyComponent.disabled, + style: legacyComponent.style, + properties: legacyComponent.properties, + items: legacyComponent.items.map(convertLegacyItemComponent), + order: legacyComponent.order, + }; + + // Convert legacy localized content to new format + if (legacyComponent.content) { + newComponent.content = legacyComponent.content.map(convertLegacyLocalizedObjectToContent); + // Extract translations from legacy localized objects + const translations = extractTranslationsFromLegacyObjects(legacyComponent.content); + if (Object.keys(translations).length > 0) { + newComponent.translations = translations; + } + } + + if (legacyComponent.description) { + // Add description as content if not already present + const descriptionContent = legacyComponent.description.map(convertLegacyLocalizedObjectToContent); + if (!newComponent.content) { + newComponent.content = []; + } + newComponent.content.push(...descriptionContent); + + // Extract translations from description + const descTranslations = extractTranslationsFromLegacyObjects(legacyComponent.description); + if (Object.keys(descTranslations).length > 0) { + if (!newComponent.translations) { + newComponent.translations = {}; + } + // Merge description translations + Object.keys(descTranslations).forEach(locale => { + if (!newComponent.translations![locale]) { + newComponent.translations![locale] = {}; + } + Object.assign(newComponent.translations![locale], descTranslations[locale]); + }); + } + } + + return newComponent; +} + +function convertItemGroupComponentToLegacy(component: ItemGroupComponent): LegacyItemGroupComponent { + const legacyComponent: LegacyItemGroupComponent = { + role: component.role, + key: component.key, + displayCondition: component.displayCondition, + disabled: component.disabled, + style: component.style, + properties: component.properties, + items: component.items.map(convertItemComponentToLegacy), + order: component.order, + }; + + // Convert new format content and translations to legacy format + if (component.content && component.translations) { + legacyComponent.content = component.content.map(content => + convertContentAndTranslationsToLegacyObject(content, component.translations!) + ); + } else if (component.content) { + // Content without translations - create simple legacy objects + legacyComponent.content = component.content.map(content => ({ + code: 'en', // Default language + parts: [{ str: content.key, dtype: 'str' as ExpressionArgDType }] // Use ExpressionArg format + })); + } + + return legacyComponent; +} + +function convertLegacyResponseComponent(legacyComponent: LegacyResponseComponent): ResponseComponent { + const newComponent: ResponseComponent = { + role: legacyComponent.role, + key: legacyComponent.key!, + displayCondition: legacyComponent.displayCondition, + disabled: legacyComponent.disabled, + style: legacyComponent.style, + properties: legacyComponent.properties, + dtype: legacyComponent.dtype, + }; + + // Convert legacy localized content to new format + if (legacyComponent.content) { + newComponent.content = legacyComponent.content.map(convertLegacyLocalizedObjectToContent); + // Extract translations from legacy localized objects + const translations = extractTranslationsFromLegacyObjects(legacyComponent.content); + if (Object.keys(translations).length > 0) { + newComponent.translations = translations; + } + } + + return newComponent; +} + +function convertResponseComponentToLegacy(component: ResponseComponent): LegacyResponseComponent { + const legacyComponent: LegacyResponseComponent = { + role: component.role, + key: component.key, + displayCondition: component.displayCondition, + disabled: component.disabled, + style: component.style, + properties: component.properties, + dtype: component.dtype, + }; + + // Convert new format content and translations to legacy format + if (component.content && component.translations) { + legacyComponent.content = component.content.map(content => + convertContentAndTranslationsToLegacyObject(content, component.translations!) + ); + } else if (component.content) { + // Content without translations - create simple legacy objects + legacyComponent.content = component.content.map(content => ({ + code: 'en', // Default language + parts: [{ str: content.key, dtype: 'str' as ExpressionArgDType }] // Use ExpressionArg format + })); + } + + return legacyComponent; +} + +// Helper functions for converting localized content +function convertLegacyLocalizedObjectToContent(legacyObj: LegacyLocalizedObject): LocalizedContent { + // For now, we'll use the resolved text or first part as the key + let key = ''; + if (legacyObj.resolvedText) { + key = legacyObj.resolvedText; + } else if (legacyObj.parts && legacyObj.parts.length > 0) { + const firstPart = legacyObj.parts[0]; + if (typeof firstPart === 'string') { + key = firstPart; + } else if (typeof firstPart === 'object' && firstPart.str) { + key = firstPart.str; + } else { + key = String(firstPart); + } + } else { + key = legacyObj.code; + } + + return { + type: 'plain', // Default type + key: key + }; +} + +function extractTranslationsFromLegacyObjects(legacyObjects: LegacyLocalizedObject[]): { [locale: string]: { [key: string]: string } } { + const translations: { [locale: string]: { [key: string]: string } } = {}; + + legacyObjects.forEach(obj => { + if (!translations[obj.code]) { + translations[obj.code] = {}; + } + + // Use resolved text if available, otherwise join parts + let text = ''; + if (obj.resolvedText) { + text = obj.resolvedText; + } else if (obj.parts) { + text = obj.parts.map(part => String(part)).join(''); + } + + // Use the first part or resolved text as the key + let key = ''; + if (obj.parts && obj.parts.length > 0) { + key = String(obj.parts[0]); + } else if (obj.resolvedText) { + key = obj.resolvedText; + } else { + key = obj.code; + } + + translations[obj.code][key] = text; + }); + + return translations; +} + +function convertContentAndTranslationsToLegacyObject( + content: LocalizedContent, + translations: { [locale: string]: { [key: string]: string } } +): LegacyLocalizedObject { + // Find the first locale that has a translation for this content key + const locales = Object.keys(translations); + const firstLocale = locales.find(locale => + translations[locale] && translations[locale][content.key] + ) || (locales.length > 0 ? locales[0] : 'en'); + + const translatedText = translations[firstLocale] && translations[firstLocale][content.key] + ? translations[firstLocale][content.key] + : content.key; + + return { + code: firstLocale, + parts: [{ str: translatedText, dtype: 'str' as ExpressionArgDType }], + resolvedText: translatedText + }; +} + +// Helper functions for converting props and validations +function convertLegacySurveyProps(legacyProps: LegacySurveyProps): SurveyProps { + const newProps: SurveyProps = {}; + + if (legacyProps.name) { + newProps.name = convertLegacyLocalizedObjectToContent(legacyProps.name[0]); + } + + if (legacyProps.description) { + newProps.description = convertLegacyLocalizedObjectToContent(legacyProps.description[0]); + } + + if (legacyProps.typicalDuration) { + newProps.typicalDuration = convertLegacyLocalizedObjectToContent(legacyProps.typicalDuration[0]); + } + + // Extract translations from legacy props + const translations: { [key: string]: { name?: string; description?: string; typicalDuration?: string; } } = {}; + + if (legacyProps.name) { + legacyProps.name.forEach(obj => { + if (!translations[obj.code]) { + translations[obj.code] = {}; + } + translations[obj.code].name = obj.resolvedText || (obj.parts ? obj.parts.map(part => + typeof part === 'string' ? part : part.str || '' + ).join('') : ''); + }); + } + + if (legacyProps.description) { + legacyProps.description.forEach(obj => { + if (!translations[obj.code]) { + translations[obj.code] = {}; + } + translations[obj.code].description = obj.resolvedText || (obj.parts ? obj.parts.map(part => + typeof part === 'string' ? part : part.str || '' + ).join('') : ''); + }); + } + + if (legacyProps.typicalDuration) { + legacyProps.typicalDuration.forEach(obj => { + if (!translations[obj.code]) { + translations[obj.code] = {}; + } + translations[obj.code].typicalDuration = obj.resolvedText || (obj.parts ? obj.parts.map(part => + typeof part === 'string' ? part : part.str || '' + ).join('') : ''); + }); + } + + if (Object.keys(translations).length > 0) { + newProps.translations = translations; + } + + return newProps; +} + +function convertSurveyPropsToLegacy(props: SurveyProps): LegacySurveyProps { + const legacyProps: LegacySurveyProps = {}; + + // Convert props with translations if available + if (props.name) { + if (props.translations) { + // Use translations if available + legacyProps.name = Object.keys(props.translations).map(locale => ({ + code: locale, + parts: [{ str: props.translations![locale].name || props.name!.key, dtype: 'str' as ExpressionArgDType }], + resolvedText: props.translations![locale].name || props.name!.key + })); + } else { + // Fallback to content key + legacyProps.name = [{ + code: 'en', // Default language + parts: [{ str: props.name.key, dtype: 'str' as ExpressionArgDType }], + resolvedText: props.name.key + }]; + } + } + + if (props.description) { + if (props.translations) { + // Use translations if available + legacyProps.description = Object.keys(props.translations).map(locale => ({ + code: locale, + parts: [{ str: props.translations![locale].description || props.description!.key, dtype: 'str' as ExpressionArgDType }], + resolvedText: props.translations![locale].description || props.description!.key + })); + } else { + // Fallback to content key + legacyProps.description = [{ + code: 'en', // Default language + parts: [{ str: props.description.key, dtype: 'str' as ExpressionArgDType }], + resolvedText: props.description.key + }]; + } + } + + if (props.typicalDuration) { + if (props.translations) { + // Use translations if available + legacyProps.typicalDuration = Object.keys(props.translations).map(locale => ({ + code: locale, + parts: [{ str: props.translations![locale].typicalDuration || props.typicalDuration!.key, dtype: 'str' as ExpressionArgDType }], + resolvedText: props.translations![locale].typicalDuration || props.typicalDuration!.key + })); + } else { + // Fallback to content key + legacyProps.typicalDuration = [{ + code: 'en', // Default language + parts: [{ str: props.typicalDuration.key, dtype: 'str' as ExpressionArgDType }], + resolvedText: props.typicalDuration.key + }]; + } + } + + return legacyProps; +} + +function convertLegacyValidation(legacyValidation: LegacyValidation): Validation { + return { + key: legacyValidation.key, + type: legacyValidation.type, + rule: legacyValidation.rule, + }; +} + +function convertValidationToLegacy(validation: Validation): LegacyValidation { + return { + key: validation.key, + type: validation.type, + rule: validation.rule, + }; +} + */ \ No newline at end of file diff --git a/src/selection-method.ts b/src/selection-method.ts deleted file mode 100644 index 11159fd..0000000 --- a/src/selection-method.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Expression } from "./data_types"; - -export class SelectionMethod { - - static pickAnItem(items: Array, expression?: Expression): any { - if (!expression) { - return this.uniformRandomSelector(items); - } - switch (expression.name) { - case 'uniform': - return this.uniformRandomSelector(items); - case 'highestPriority': - return this.selectHighestPriority(items); - case 'exponential': - return this.exponentialRandomSelector(items, expression); - default: - console.error('pickAnItem: expression name is not known: ' + expression.name); - return this.uniformRandomSelector(items); - } - } - - private static uniformRandomSelector(items: Array): any { - if (items.length < 1) { - return; - } - return items[Math.floor(Math.random() * items.length)]; - } - - private static exponentialRandomSelector(items: Array, expression?: Expression): any { - if (items.length < 1 || !expression || !expression.data || expression.data.length !== 1) { - return; - } - if (!expression.data[0].num) { - return; - } - // TODO: rate is pointless right now - adapt formula if necessary - const rate = expression.data[0].num; - const scaling = -Math.log(0.002) / rate; - - const sorted = this.sortByPriority(items); - const uniform = Math.random(); - - let exp = (-1 / rate) * Math.log(uniform) / scaling; - if (exp > 1) { - exp = 1; - } - let index = Math.floor(exp * items.length); - if (index >= items.length) { - index = items.length - 1; - } - return sorted[index]; - } - - private static selectHighestPriority(items: Array): any { - if (items.length < 1) { - return; - } - const sorted = this.sortByPriority(items); - return sorted[0]; - } - - private static sortByPriority(items: Array): Array { - return items.sort((a, b) => a.priority > b.priority ? -1 : a.priority < b.priority ? 1 : 0); - } -} \ No newline at end of file diff --git a/src/survey-editor/component-editor.ts b/src/survey-editor/component-editor.ts new file mode 100644 index 0000000..6edbfa7 --- /dev/null +++ b/src/survey-editor/component-editor.ts @@ -0,0 +1,105 @@ +import { Expression } from "../expressions"; +import { DisplayComponent, ItemComponent, ItemComponentType, ScgMcgOption, ScgMcgOptionBase } from "../survey/components"; +import { Content } from "../survey/utils/content"; +import { QuestionEditor, SurveyItemEditor } from "./survey-item-editors"; + + +abstract class ComponentEditor { + protected _itemEditor: SurveyItemEditor; + protected _component: ItemComponent; + + constructor(itemEditor: SurveyItemEditor, component: ItemComponent) { + this._itemEditor = itemEditor; + this._component = component; + } + + delete(): void { + this._itemEditor.deleteComponent(this._component); + } + + updateContent(locale: string, content?: Content, contentKey?: string): void { + this._itemEditor.updateComponentTranslations({ componentFullKey: this._component.key.fullKey, contentKey }, locale, content) + } + + setDisplayCondition(condition: Expression | undefined): void { + this._itemEditor.setDisplayCondition(condition, this._component.key.fullKey); + } + + getDisplayCondition(): Expression | undefined { + return this._itemEditor.getDisplayCondition(this._component.key.fullKey); + } + + changeKey(newKey: string, isFullKey: boolean = false): void { + const oldKey = this._component.key.fullKey; + + // Update through the item editor which will handle the survey editor + let newFullKey = newKey; + if (!isFullKey) { + // Handle case where component has no parent component (direct child of survey item) + if (this._component.key.parentFullKey) { + newFullKey = this._component.key.parentFullKey + '.' + newKey; + } else { + newFullKey = newKey; // Component is direct child of survey item + } + } + + this._itemEditor.changeComponentKey(oldKey, newFullKey); + } +} + + +export class DisplayComponentEditor extends ComponentEditor { + constructor(itemEditor: SurveyItemEditor, component: DisplayComponent) { + super(itemEditor, component); + } +} + +// ================================ +// Response related components +// ================================ + +export abstract class ResponseComponentEditor extends ComponentEditor { + constructor(itemEditor: SurveyItemEditor, component: ItemComponent) { + super(itemEditor, component); + } +} + + +export abstract class ScgMcgOptionBaseEditor extends ResponseComponentEditor { + constructor(itemEditor: SurveyItemEditor, component: ScgMcgOptionBase) { + super(itemEditor, component); + } + + static fromOption(itemEditor: SurveyItemEditor, option: ScgMcgOptionBase): ScgMcgOptionBaseEditor { + switch (option.componentType) { + case ItemComponentType.ScgMcgOption: + return new ScgMcgOptionEditor(itemEditor, option as ScgMcgOption); + default: + throw new Error(`Unsupported option type: ${option.componentType}`); + } + } + // TODO: update option key + + + setDisableCondition(condition: Expression | undefined): void { + (this._itemEditor as QuestionEditor).setDisableCondition(condition, this._component.key.fullKey); + } + getDisableCondition(): Expression | undefined { + return (this._itemEditor as QuestionEditor).getDisableCondition(this._component.key.fullKey); + } + + // convienience methods to quickly set validations or template values related to the option + // TODO: add validation + // TODO: add template value + +} + +export class ScgMcgOptionEditor extends ScgMcgOptionBaseEditor { + constructor(itemEditor: SurveyItemEditor, component: ScgMcgOption) { + super(itemEditor, component); + } + + + // TODO: update option type specific properties + +} diff --git a/src/survey-editor/expression-editor-generators.ts b/src/survey-editor/expression-editor-generators.ts new file mode 100644 index 0000000..d0d27b4 --- /dev/null +++ b/src/survey-editor/expression-editor-generators.ts @@ -0,0 +1,194 @@ +import { ExpectedValueType } from "../survey"; +import { + AndExpressionEditor, + ConstStringArrayEditor, + ConstStringEditor, + ExpressionEditor, + StrListContainsExpressionEditor, + OrExpressionEditor, + ResponseVariableEditor, + ConstNumberArrayEditor, + ConstNumberEditor, + ConstBooleanEditor, + ConstDateEditor, + ConstDateArrayEditor, + StrEqExpressionEditor, + EqExpressionEditor, + GtExpressionEditor, + GteExpressionEditor, + LteExpressionEditor, + LtExpressionEditor, + InRangeExpressionEditor, + SumExpressionEditor, + MinExpressionEditor, + MaxExpressionEditor, + CtxLocaleEditor, + CtxPFlagIsDefinedEditor, + CtxPFlagStringEditor, + CtxPFlagNumEditor, + CtxPFlagDateEditor, + CtxCustomValueEditor, + CtxCustomExpressionEditor, +} from "./expression-editor"; + +// ================================ +// CONST EXPRESSIONS +// ================================ +export const const_string_array = (...values: string[]): ExpressionEditor => { + return new ConstStringArrayEditor(values); +} + +export const const_string = (value: string): ExpressionEditor => { + return new ConstStringEditor(value); +} + +export const const_number_array = (...values: number[]): ExpressionEditor => { + return new ConstNumberArrayEditor(values); +} + +export const const_number = (value: number): ExpressionEditor => { + return new ConstNumberEditor(value); +} + +export const const_boolean = (value: boolean): ExpressionEditor => { + return new ConstBooleanEditor(value); +} + +export const const_date = (value: Date): ExpressionEditor => { + return new ConstDateEditor(value); +} + +export const const_date_array = (...values: Date[]): ExpressionEditor => { + return new ConstDateArrayEditor(values); +} + + +// ================================ +// RESPONSE VARIABLE EXPRESSIONS +// ================================ +export const response_string = (valueRef: string): ExpressionEditor => { + return new ResponseVariableEditor(valueRef, ExpectedValueType.String); +} + +export const response_string_array = (valueRef: string): ExpressionEditor => { + return new ResponseVariableEditor(valueRef, ExpectedValueType.StringArray); +} + +export const response_number = (valueRef: string): ExpressionEditor => { + return new ResponseVariableEditor(valueRef, ExpectedValueType.Number); +} + +export const response_boolean = (valueRef: string): ExpressionEditor => { + return new ResponseVariableEditor(valueRef, ExpectedValueType.Boolean); +} + +export const response_date = (valueRef: string): ExpressionEditor => { + return new ResponseVariableEditor(valueRef, ExpectedValueType.Date); +} + +export const response_number_array = (valueRef: string): ExpressionEditor => { + return new ResponseVariableEditor(valueRef, ExpectedValueType.NumberArray); +} + +export const response_date_array = (valueRef: string): ExpressionEditor => { + return new ResponseVariableEditor(valueRef, ExpectedValueType.DateArray); +} + +// ================================ +// CONTEXT VARIABLE EXPRESSIONS +// ================================ +export const ctx_locale = (): ExpressionEditor => { + return new CtxLocaleEditor(); +} + +export const ctx_pflag_is_defined = (key: ExpressionEditor): ExpressionEditor => { + return new CtxPFlagIsDefinedEditor(key); +} + +export const ctx_pflag_string = (key: ExpressionEditor): ExpressionEditor => { + return new CtxPFlagStringEditor(key); +} + +export const ctx_pflag_num = (key: ExpressionEditor): ExpressionEditor => { + return new CtxPFlagNumEditor(key); +} + +export const ctx_pflag_date = (key: ExpressionEditor): ExpressionEditor => { + return new CtxPFlagDateEditor(key); +} + +export const ctx_custom_value = (key: ExpressionEditor, dType: ExpectedValueType): ExpressionEditor => { + return new CtxCustomValueEditor(key, dType); +} + +export const ctx_custom_expression = (key: ExpressionEditor, args: ExpressionEditor[], returnType: ExpectedValueType): ExpressionEditor => { + return new CtxCustomExpressionEditor(key, args, returnType); +} + +// ================================ +// LOGIC EXPRESSIONS +// ================================ +export const and = (...args: ExpressionEditor[]): ExpressionEditor => { + return new AndExpressionEditor(args); +} + +export const or = (...args: ExpressionEditor[]): ExpressionEditor => { + return new OrExpressionEditor(args); +} + + +// ================================ +// LIST EXPRESSIONS +// ================================ + +export const str_list_contains = (list: ExpressionEditor, item: ExpressionEditor): ExpressionEditor => { + return new StrListContainsExpressionEditor(list, item); +} + +// ================================ +// STRING EXPRESSIONS +// ================================ + +export const str_eq = (a: ExpressionEditor, b: ExpressionEditor): ExpressionEditor => { + return new StrEqExpressionEditor(a, b); +} + +// ================================ +// NUMBER EXPRESSIONS +// ================================ + +export const eq = (a: ExpressionEditor, b: ExpressionEditor): ExpressionEditor => { + return new EqExpressionEditor(a, b); +} + +export const gt = (a: ExpressionEditor, b: ExpressionEditor): ExpressionEditor => { + return new GtExpressionEditor(a, b); +} + +export const gte = (a: ExpressionEditor, b: ExpressionEditor): ExpressionEditor => { + return new GteExpressionEditor(a, b); +} + +export const lt = (a: ExpressionEditor, b: ExpressionEditor): ExpressionEditor => { + return new LtExpressionEditor(a, b); +} + +export const lte = (a: ExpressionEditor, b: ExpressionEditor): ExpressionEditor => { + return new LteExpressionEditor(a, b); +} + +export const in_range = (value: ExpressionEditor, min: ExpressionEditor, max: ExpressionEditor, inclusive: ExpressionEditor): ExpressionEditor => { + return new InRangeExpressionEditor(value, min, max, inclusive); +} + +export const sum = (...args: ExpressionEditor[]): ExpressionEditor => { + return new SumExpressionEditor(args); +} + +export const min = (...args: ExpressionEditor[]): ExpressionEditor => { + return new MinExpressionEditor(args); +} + +export const max = (...args: ExpressionEditor[]): ExpressionEditor => { + return new MaxExpressionEditor(args); +} diff --git a/src/survey-editor/expression-editor.ts b/src/survey-editor/expression-editor.ts new file mode 100644 index 0000000..2678ba7 --- /dev/null +++ b/src/survey-editor/expression-editor.ts @@ -0,0 +1,745 @@ + + +// TODO: constant expression editor +// TODO: context variable expression editor +// TODO: function expression editor + +import { Expression, FunctionExpression, ExpressionEditorConfig, FunctionExpressionNames, ConstExpression, ResponseVariableExpression, ContextVariableType, ContextVariableExpression } from "../expressions/expression"; +import { ExpectedValueType, ValueReference } from "../survey"; + + +// ================================ +// EXPRESSION EDITOR CLASSES +// ================================ +export abstract class ExpressionEditor { + returnType!: ExpectedValueType; + protected _editorConfig?: ExpressionEditorConfig; + + abstract getExpression(): Expression | undefined + + get editorConfig(): ExpressionEditorConfig | undefined { + return this._editorConfig; + } + + withEditorConfig(editorConfig: ExpressionEditorConfig): ExpressionEditor { + this._editorConfig = editorConfig; + return this; + } +} + +// ================================ +// CONST EDITORS +// ================================ +export class ConstStringArrayEditor extends ExpressionEditor { + readonly returnType = ExpectedValueType.StringArray; + + private _values: string[]; + + constructor(values: string[], editorConfig?: ExpressionEditorConfig) { + super(); + this._values = values; + this._editorConfig = editorConfig; + } + + get values(): string[] { + return this._values; + } + + set values(values: string[]) { + this._values = values; + } + + getExpression(): Expression | undefined { + return new ConstExpression(this._values, this._editorConfig); + } +} + +export class ConstStringEditor extends ExpressionEditor { + readonly returnType = ExpectedValueType.String; + + private _value: string; + + constructor(value: string, editorConfig?: ExpressionEditorConfig) { + super(); + this._value = value; + this._editorConfig = editorConfig; + } + + get value(): string { + return this._value; + } + + set value(value: string) { + this._value = value; + } + + getExpression(): Expression | undefined { + return new ConstExpression(this._value, this._editorConfig); + } +} + +export class ConstNumberEditor extends ExpressionEditor { + readonly returnType = ExpectedValueType.Number; + + private _value: number; + + constructor(value: number, editorConfig?: ExpressionEditorConfig) { + super(); + this._value = value; + this._editorConfig = editorConfig; + } + + get value(): number { + return this._value; + } + + set value(value: number) { + this._value = value; + } + + getExpression(): Expression | undefined { + return new ConstExpression(this._value, this._editorConfig); + } +} + +export class ConstBooleanEditor extends ExpressionEditor { + readonly returnType = ExpectedValueType.Boolean; + + private _value: boolean; + + constructor(value: boolean, editorConfig?: ExpressionEditorConfig) { + super(); + this._value = value; + this._editorConfig = editorConfig; + } + + get value(): boolean { + return this._value; + } + + set value(value: boolean) { + this._value = value; + } + + getExpression(): Expression | undefined { + return new ConstExpression(this._value, this._editorConfig); + } +} + +export class ConstDateEditor extends ExpressionEditor { + readonly returnType = ExpectedValueType.Date; + + private _value: Date; + + constructor(value: Date, editorConfig?: ExpressionEditorConfig) { + super(); + this._value = value; + this._editorConfig = editorConfig; + } + + get value(): Date { + return this._value; + } + + set value(value: Date) { + this._value = value; + } + + getExpression(): Expression | undefined { + return new ConstExpression(this._value, this._editorConfig); + } +} + +export class ConstNumberArrayEditor extends ExpressionEditor { + readonly returnType = ExpectedValueType.NumberArray; + + private _values: number[]; + + constructor(values: number[], editorConfig?: ExpressionEditorConfig) { + super(); + this._values = values; + this._editorConfig = editorConfig; + } + + get values(): number[] { + return this._values; + } + + set values(values: number[]) { + this._values = values; + } + + getExpression(): Expression | undefined { + return new ConstExpression(this._values, this._editorConfig); + } +} + +export class ConstDateArrayEditor extends ExpressionEditor { + readonly returnType = ExpectedValueType.DateArray; + + private _values: Date[]; + + constructor(values: Date[], editorConfig?: ExpressionEditorConfig) { + super(); + this._values = values; + this._editorConfig = editorConfig; + } + + get values(): Date[] { + return this._values; + } + + set values(values: Date[]) { + this._values = values; + } + + getExpression(): Expression | undefined { + return new ConstExpression(this._values, this._editorConfig); + } +} + +// ================================ +// RESPONSE VARIABLE EXPRESSION EDITOR CLASSES +// ================================ + +export class ResponseVariableEditor extends ExpressionEditor { + private _variableName: string; + private _variableRef: ValueReference; + + constructor(variableName: string, variableType: ExpectedValueType, editorConfig?: ExpressionEditorConfig) { + super(); + this._variableName = variableName; + this._variableRef = new ValueReference(variableName); + this._editorConfig = editorConfig; + this.returnType = variableType; + } + + get variableName(): string { + return this._variableName; + } + + get variableRef(): ValueReference { + return this._variableRef; + } + + getExpression(): Expression | undefined { + return new ResponseVariableExpression(this._variableName, this._editorConfig); + } +} + +// ================================ +// CONTEXT VARIABLE EXPRESSION EDITOR CLASSES +// ================================ + +abstract class ContextVariableEditor extends ExpressionEditor { + private _contextType: ContextVariableType; + private _key?: ExpressionEditor; + private _args?: ExpressionEditor[]; + private _asType?: ExpectedValueType; + + constructor(contextType: ContextVariableType, key?: ExpressionEditor, args?: ExpressionEditor[], asType?: ExpectedValueType, editorConfig?: ExpressionEditorConfig) { + super(); + this._contextType = contextType; + this._key = key; + this._args = args; + this._asType = asType; + this._editorConfig = editorConfig; + } + + getExpression(): Expression | undefined { + return new ContextVariableExpression(this._contextType, this._key?.getExpression(), this._args?.map(arg => arg.getExpression()), this._asType, this._editorConfig); + } +} + +export class CtxLocaleEditor extends ContextVariableEditor { + readonly returnType = ExpectedValueType.String; + + constructor(editorConfig?: ExpressionEditorConfig) { + super(ContextVariableType.Locale, undefined, undefined, ExpectedValueType.String, editorConfig); + } +} + +export class CtxPFlagIsDefinedEditor extends ContextVariableEditor { + readonly returnType = ExpectedValueType.Boolean; + + constructor(key: ExpressionEditor, editorConfig?: ExpressionEditorConfig) { + super(ContextVariableType.ParticipantFlag, key, undefined, ExpectedValueType.Boolean, editorConfig); + } +} + +export class CtxPFlagStringEditor extends ContextVariableEditor { + readonly returnType = ExpectedValueType.String; + + constructor(key: ExpressionEditor, editorConfig?: ExpressionEditorConfig) { + super(ContextVariableType.ParticipantFlag, key, undefined, ExpectedValueType.String, editorConfig); + } +} + +export class CtxPFlagNumEditor extends ContextVariableEditor { + readonly returnType = ExpectedValueType.Number; + + constructor(key: ExpressionEditor, editorConfig?: ExpressionEditorConfig) { + super(ContextVariableType.ParticipantFlag, key, undefined, ExpectedValueType.Number, editorConfig); + } +} + +export class CtxPFlagDateEditor extends ContextVariableEditor { + readonly returnType = ExpectedValueType.Date; + + constructor(key: ExpressionEditor, editorConfig?: ExpressionEditorConfig) { + super(ContextVariableType.ParticipantFlag, key, undefined, ExpectedValueType.Date, editorConfig); + } +} + + +export class CtxCustomValueEditor extends ContextVariableEditor { + + constructor(key: ExpressionEditor, + asType: ExpectedValueType, + editorConfig?: ExpressionEditorConfig) { + super(ContextVariableType.CustomValue, key, undefined, asType, editorConfig); + this.returnType = asType; + } +} + +export class CtxCustomExpressionEditor extends ContextVariableEditor { + constructor(key: ExpressionEditor, + args: ExpressionEditor[], + asType: ExpectedValueType, + editorConfig?: ExpressionEditorConfig) { + super(ContextVariableType.CustomExpression, key, args, asType, editorConfig); + this.returnType = asType; + } +} + +// ================================ +// GROUP EXPRESSION EDITOR CLASSES +// ================================ +abstract class GroupExpressionEditor extends ExpressionEditor { + private _args: ExpressionEditor[]; + + constructor( + args: ExpressionEditor[], + editorConfig?: ExpressionEditorConfig + ) { + super(); + this._args = args; + this._editorConfig = editorConfig; + } + + get args(): ExpressionEditor[] { + return this._args; + } + + addArg(arg: ExpressionEditor, position?: number) { + if (position === undefined) { + this._args.push(arg); + } else { + this._args.splice(position, 0, arg); + } + } + + removeArg(position: number) { + this._args.splice(position, 1); + } + + replaceArg(position: number, arg: ExpressionEditor) { + this._args[position] = arg; + } + + swapArgs(activeIndex: number, overIndex: number) { + const newOrder = [...this._args]; + newOrder.splice(activeIndex, 1); + newOrder.splice(overIndex, 0, this._args[activeIndex]); + this._args = newOrder; + } +} + +export class AndExpressionEditor extends GroupExpressionEditor { + readonly returnType = ExpectedValueType.Boolean; + + constructor(args: ExpressionEditor[], editorConfig?: ExpressionEditorConfig) { + if (args.some(arg => arg.returnType !== ExpectedValueType.Boolean)) { + throw new Error('And expression editor must have boolean arguments'); + } + super(args, editorConfig); + } + + getExpression(): Expression | undefined { + return new FunctionExpression( + FunctionExpressionNames.and, + this.args.map(arg => arg.getExpression()), + this._editorConfig + ) + } +} + +export class OrExpressionEditor extends GroupExpressionEditor { + readonly returnType = ExpectedValueType.Boolean; + + constructor(args: ExpressionEditor[], editorConfig?: ExpressionEditorConfig) { + if (args.some(arg => arg.returnType !== ExpectedValueType.Boolean)) { + throw new Error('Or expression editor must have boolean arguments'); + } + super(args, editorConfig); + } + + getExpression(): Expression { + return new FunctionExpression( + FunctionExpressionNames.or, + this.args.map(arg => arg.getExpression()), + this._editorConfig + ) + } +} + + +// ================================ +// LIST EXPRESSION EDITOR CLASSES +// ================================ + +export class StrListContainsExpressionEditor extends ExpressionEditor { + readonly returnType = ExpectedValueType.Boolean; + private _list: ExpressionEditor | undefined; + private _item: ExpressionEditor | undefined; + + constructor(list: ExpressionEditor, item: ExpressionEditor, editorConfig?: ExpressionEditorConfig) { + super(); + if (list.returnType !== ExpectedValueType.StringArray) { + throw new Error('List contains expression editor must have a string array list'); + } + if (item.returnType !== ExpectedValueType.String) { + throw new Error('List contains expression editor must have a string item'); + } + this._list = list; + this._item = item; + this._editorConfig = editorConfig; + } + + get list(): ExpressionEditor | undefined { + return this._list; + } + + get item(): ExpressionEditor | undefined { + return this._item; + } + + set list(list: ExpressionEditor | undefined) { + this._list = list; + } + + set item(item: ExpressionEditor | undefined) { + this._item = item; + } + + getExpression(): Expression { + return new FunctionExpression( + FunctionExpressionNames.list_contains, + [this._list?.getExpression(), this._item?.getExpression()], + this._editorConfig + ); + } +} + +// ================================ +// STRING EXPRESSION EDITOR CLASSES +// ================================ + +export class StrEqExpressionEditor extends ExpressionEditor { + readonly returnType = ExpectedValueType.Boolean; + private _a: ExpressionEditor | undefined; + private _b: ExpressionEditor | undefined; + + constructor(a: ExpressionEditor, b: ExpressionEditor, editorConfig?: ExpressionEditorConfig) { + super(); + this._a = a; + this._b = b; + this._editorConfig = editorConfig; + } + + get a(): ExpressionEditor | undefined { + return this._a; + } + + get b(): ExpressionEditor | undefined { + return this._b; + } + + set a(a: ExpressionEditor | undefined) { + this._a = a; + } + + set b(b: ExpressionEditor | undefined) { + this._b = b; + } + + getExpression(): Expression { + return new FunctionExpression( + FunctionExpressionNames.str_eq, + [this._a?.getExpression(), this._b?.getExpression()], + this._editorConfig + ); + } +} + +// ================================ +// NUMBER EXPRESSION EDITOR CLASSES +// ================================ + +export class EqExpressionEditor extends ExpressionEditor { + readonly returnType = ExpectedValueType.Boolean; + private _a: ExpressionEditor | undefined; + private _b: ExpressionEditor | undefined; + + constructor(a: ExpressionEditor, b: ExpressionEditor, editorConfig?: ExpressionEditorConfig) { + super(); + this._a = a; + this._b = b; + this._editorConfig = editorConfig; + } + + get a(): ExpressionEditor | undefined { + return this._a; + } + + get b(): ExpressionEditor | undefined { + return this._b; + } + + set a(a: ExpressionEditor | undefined) { + this._a = a; + } + + set b(b: ExpressionEditor | undefined) { + this._b = b; + } + + getExpression(): Expression { + return new FunctionExpression( + FunctionExpressionNames.eq, + [this._a?.getExpression(), this._b?.getExpression()], + this._editorConfig + ); + } +} + +export class GtExpressionEditor extends ExpressionEditor { + readonly returnType = ExpectedValueType.Boolean; + private _a: ExpressionEditor | undefined; + private _b: ExpressionEditor | undefined; + + constructor(a: ExpressionEditor, b: ExpressionEditor, editorConfig?: ExpressionEditorConfig) { + super(); + this._a = a; + this._b = b; + this._editorConfig = editorConfig; + } + + get a(): ExpressionEditor | undefined { + return this._a; + } + + get b(): ExpressionEditor | undefined { + return this._b; + } + + set a(a: ExpressionEditor | undefined) { + this._a = a; + } + + set b(b: ExpressionEditor | undefined) { + this._b = b; + } + + getExpression(): Expression { + return new FunctionExpression( + FunctionExpressionNames.gt, + [this._a?.getExpression(), this._b?.getExpression()], + this._editorConfig + ); + } +} + +export class GteExpressionEditor extends ExpressionEditor { + readonly returnType = ExpectedValueType.Boolean; + private _a: ExpressionEditor | undefined; + private _b: ExpressionEditor | undefined; + + constructor(a: ExpressionEditor, b: ExpressionEditor, editorConfig?: ExpressionEditorConfig) { + super(); + this._a = a; + this._b = b; + this._editorConfig = editorConfig; + } + + get a(): ExpressionEditor | undefined { + return this._a; + } + + get b(): ExpressionEditor | undefined { + return this._b; + } + + set a(a: ExpressionEditor | undefined) { + this._a = a; + } + + set b(b: ExpressionEditor | undefined) { + this._b = b; + } + + getExpression(): Expression { + return new FunctionExpression( + FunctionExpressionNames.gte, + [this._a?.getExpression(), this._b?.getExpression()], + this._editorConfig + ); + } +} + +export class LtExpressionEditor extends ExpressionEditor { + readonly returnType = ExpectedValueType.Boolean; + private _a: ExpressionEditor | undefined; + private _b: ExpressionEditor | undefined; + + constructor(a: ExpressionEditor, b: ExpressionEditor, editorConfig?: ExpressionEditorConfig) { + super(); + this._a = a; + this._b = b; + this._editorConfig = editorConfig; + } + + get a(): ExpressionEditor | undefined { + return this._a; + } + + get b(): ExpressionEditor | undefined { + return this._b; + } + + set a(a: ExpressionEditor | undefined) { + this._a = a; + } + + set b(b: ExpressionEditor | undefined) { + this._b = b; + } + + getExpression(): Expression { + return new FunctionExpression( + FunctionExpressionNames.lt, + [this._a?.getExpression(), this._b?.getExpression()], + this._editorConfig + ); + } +} + +export class LteExpressionEditor extends ExpressionEditor { + readonly returnType = ExpectedValueType.Boolean; + private _a: ExpressionEditor | undefined; + private _b: ExpressionEditor | undefined; + + constructor(a: ExpressionEditor, b: ExpressionEditor, editorConfig?: ExpressionEditorConfig) { + super(); + this._a = a; + this._b = b; + this._editorConfig = editorConfig; + } + + get a(): ExpressionEditor | undefined { + return this._a; + } + + get b(): ExpressionEditor | undefined { + return this._b; + } + + set b(b: ExpressionEditor | undefined) { + this._b = b; + } + + set a(a: ExpressionEditor | undefined) { + this._a = a; + } + + getExpression(): Expression { + return new FunctionExpression( + FunctionExpressionNames.lte, + [this._a?.getExpression(), this._b?.getExpression()], + this._editorConfig + ); + } +} + +export class InRangeExpressionEditor extends ExpressionEditor { + readonly returnType = ExpectedValueType.Boolean; + value: ExpressionEditor | undefined; + min: ExpressionEditor | undefined; + max: ExpressionEditor | undefined; + inclusive: ExpressionEditor | undefined; + + constructor(value: ExpressionEditor, min: ExpressionEditor, max: ExpressionEditor, inclusive: ExpressionEditor, editorConfig?: ExpressionEditorConfig) { + super(); + this.value = value; + this.min = min; + this.max = max; + this.inclusive = inclusive; + this._editorConfig = editorConfig; + } + + getExpression(): Expression { + return new FunctionExpression( + FunctionExpressionNames.in_range, + [this.value?.getExpression(), this.min?.getExpression(), this.max?.getExpression(), this.inclusive?.getExpression()], + this._editorConfig + ); + } +} + +export class SumExpressionEditor extends GroupExpressionEditor { + readonly returnType = ExpectedValueType.Number; + + constructor(args: ExpressionEditor[], editorConfig?: ExpressionEditorConfig) { + super(args, editorConfig); + } + + getExpression(): Expression { + return new FunctionExpression( + FunctionExpressionNames.sum, + this.args.map(arg => arg.getExpression()), + this._editorConfig + ); + } +} + +export class MinExpressionEditor extends GroupExpressionEditor { + readonly returnType = ExpectedValueType.Number; + + constructor(args: ExpressionEditor[], editorConfig?: ExpressionEditorConfig) { + super(args, editorConfig); + } + + getExpression(): Expression { + return new FunctionExpression( + FunctionExpressionNames.min, + this.args.map(arg => arg.getExpression()), + this._editorConfig + ); + } +} + +export class MaxExpressionEditor extends GroupExpressionEditor { + readonly returnType = ExpectedValueType.Number; + + constructor(args: ExpressionEditor[], editorConfig?: ExpressionEditorConfig) { + super(args, editorConfig); + } + + getExpression(): Expression { + return new FunctionExpression( + FunctionExpressionNames.max, + this.args.map(arg => arg.getExpression()), + this._editorConfig + ); + } +} diff --git a/src/survey-editor/index.ts b/src/survey-editor/index.ts new file mode 100644 index 0000000..b8a6a2c --- /dev/null +++ b/src/survey-editor/index.ts @@ -0,0 +1,5 @@ +export * from './expression-editor'; +export * from './expression-editor-generators'; +export * from './survey-editor'; +export * from './component-editor'; +export * from './survey-item-editors'; \ No newline at end of file diff --git a/src/survey-editor/survey-editor.ts b/src/survey-editor/survey-editor.ts new file mode 100644 index 0000000..4945482 --- /dev/null +++ b/src/survey-editor/survey-editor.ts @@ -0,0 +1,658 @@ +import { Survey } from "../survey/survey"; +import { SurveyItem, GroupItem, SurveyItemType, SingleChoiceQuestionItem } from "../survey/items"; +import { SurveyEditorUndoRedo, type UndoRedoConfig } from "./undo-redo"; +import { SurveyItemTranslations } from "../survey/utils"; +import { SurveyItemKey } from "../survey/item-component-key"; +import { JsonSurvey } from "../survey/survey-file-schema"; + +// Interface for serializing SurveyEditor state +export interface SurveyEditorJson { + version: string; + survey: JsonSurvey; + undoRedo: ReturnType; + hasUncommittedChanges: boolean; +} + +// Event types for the editor +export type SurveyEditorEventType = 'survey-changed'; + +// Event data interfaces +export interface SurveyEditorEventData { + 'survey-changed': { + hasUncommittedChanges: boolean; + isCommit: boolean; + description?: string; + }; +} + +export type SurveyEditorEventListener = (data: SurveyEditorEventData[T]) => void; + +// Since we only have one event type, we can be specific about the listener type +type AnyEventListener = SurveyEditorEventListener; + +export class SurveyEditor { + private _survey: Survey; + private _undoRedo: SurveyEditorUndoRedo; + private _hasUncommittedChanges: boolean = false; + private _listeners: Map> = new Map(); + + constructor(survey: Survey) { + this._survey = survey; + this._undoRedo = new SurveyEditorUndoRedo(survey.toJson()); + } + + // Event listener management + on(event: T, listener: SurveyEditorEventListener): void { + if (!this._listeners.has(event)) { + this._listeners.set(event, new Set()); + } + this._listeners.get(event)!.add(listener as AnyEventListener); + } + + off(event: T, listener: SurveyEditorEventListener): void { + const eventListeners = this._listeners.get(event); + if (eventListeners) { + eventListeners.delete(listener as AnyEventListener); + if (eventListeners.size === 0) { + this._listeners.delete(event); + } + } + } + + // Clear all listeners for cleanup + clearAllListeners(): void { + this._listeners.clear(); + } + + // Get listener count for debugging + getListenerCount(event?: SurveyEditorEventType): number { + if (event) { + return this._listeners.get(event)?.size || 0; + } + let total = 0; + this._listeners.forEach(listeners => total += listeners.size); + return total; + } + + private emit(event: T, data: SurveyEditorEventData[T]): void { + const eventListeners = this._listeners.get(event); + if (eventListeners) { + eventListeners.forEach(listener => { + try { + (listener as SurveyEditorEventListener)(data); + } catch (error) { + console.error(`Error in ${event} listener:`, error); + } + }); + } + } + + get survey(): Survey { + return this._survey; + } + + get hasUncommittedChanges(): boolean { + return this._hasUncommittedChanges; + } + + // Expose the undo-redo instance directly + get undoRedo(): SurveyEditorUndoRedo { + return this._undoRedo; + } + + // Commit current changes to undo/redo history + commit(description: string): void { + this._undoRedo.commit(this._survey.toJson(), description); + this._hasUncommittedChanges = false; + this.emit('survey-changed', { + hasUncommittedChanges: this._hasUncommittedChanges, + isCommit: true, + description + }); + } + + commitIfNeeded(): void { + if (this._hasUncommittedChanges) { + this.commit('Latest content changes'); + } + } + + // Undo to previous state + undo(): boolean { + if (this._hasUncommittedChanges) { + // If there are uncommitted changes, revert to last committed state + this._survey = Survey.fromJson(this._undoRedo.getCurrentState()); + this._hasUncommittedChanges = false; + return true; + } else { + // Normal undo operation + const previousState = this._undoRedo.undo(); + if (previousState) { + this._survey = Survey.fromJson(previousState); + this._hasUncommittedChanges = false; + this.emit('survey-changed', { + hasUncommittedChanges: this._hasUncommittedChanges, + isCommit: false + }); + return true; + } + return false; + } + } + + // Redo to next state + redo(): boolean { + if (this._hasUncommittedChanges) { + // Cannot redo when there are uncommitted changes + return false; + } + + const nextState = this._undoRedo.redo(); + if (nextState) { + this._survey = Survey.fromJson(nextState); + this._hasUncommittedChanges = false; + this.emit('survey-changed', { + hasUncommittedChanges: this._hasUncommittedChanges, + isCommit: false + }); + return true; + } + return false; + } + + // Enhanced undo/redo methods that use the exposed instance + + /** + * Jump to a specific index in the history (can go forward or backward) + */ + jumpToIndex(targetIndex: number): boolean { + if (this._hasUncommittedChanges) { + // Cannot jump to specific index with uncommitted changes + return false; + } + + const targetState = this._undoRedo.jumpToIndex(targetIndex); + if (targetState) { + this._survey = Survey.fromJson(targetState); + this._hasUncommittedChanges = false; + this.emit('survey-changed', { + hasUncommittedChanges: this._hasUncommittedChanges, + isCommit: false + }); + return true; + } + return false; + } + + canUndo(): boolean { + return this._hasUncommittedChanges || this._undoRedo.canUndo(); + } + + canRedo(): boolean { + return !this._hasUncommittedChanges && this._undoRedo.canRedo(); + } + + getUndoDescription(): string | null { + if (this._hasUncommittedChanges) { + return 'Latest content changes'; + } + return this._undoRedo.getUndoDescription(); + } + + getRedoDescription(): string | null { + if (this._hasUncommittedChanges) { + return null; + } + return this._undoRedo.getRedoDescription(); + } + + // Get memory usage statistics + getMemoryUsage(): { totalMB: number; entries: number } { + return this._undoRedo.getMemoryUsage(); + } + + // Get undo/redo configuration + getUndoRedoConfig(): UndoRedoConfig { + return this._undoRedo.getConfig(); + } + + /** + * Serialize the SurveyEditor state to JSON + * @returns A JSON-serializable object containing the complete editor state + */ + toJson(): SurveyEditorJson { + return { + version: '1.0.0', + survey: this._survey.toJson(), + undoRedo: this._undoRedo.toJSON(), + hasUncommittedChanges: this._hasUncommittedChanges + }; + } + + /** + * Create a new SurveyEditor instance from JSON data + * @param jsonData The serialized editor state + * @returns A new SurveyEditor instance with the restored state + */ + static fromJson(jsonData: SurveyEditorJson): SurveyEditor { + if (!jsonData.survey) { + throw new Error('Invalid JSON data: survey is required'); + } + + if (!jsonData.undoRedo) { + throw new Error('Invalid JSON data: undoRedo is required'); + } + + if (typeof jsonData.hasUncommittedChanges !== 'boolean') { + throw new Error('Invalid JSON data: hasUncommittedChanges must be a boolean'); + } + + // Validate version (for future compatibility) + if (jsonData.version && !jsonData.version.startsWith('1.')) { + console.warn(`Warning: Loading SurveyEditor with version ${jsonData.version}, current version is 1.0.0`); + } + + // Create survey from JSON + const survey = Survey.fromJson(jsonData.survey); + + // Create a new editor instance + const editor = new SurveyEditor(survey); + + // Restore undo/redo state + editor._undoRedo = SurveyEditorUndoRedo.fromJSON(jsonData.undoRedo); + + // Restore uncommitted changes flag + editor._hasUncommittedChanges = jsonData.hasUncommittedChanges; + + return editor; + } + + private markAsModified(): void { + this._hasUncommittedChanges = true; + this.emit('survey-changed', { + hasUncommittedChanges: this._hasUncommittedChanges, + isCommit: false + }); + } + + private updateItemKeyReferencesInSurvey(oldFullKey: string, newFullKey: string): void { + // Update references in all survey items + for (const item of Object.values(this._survey.surveyItems)) { + // Update display conditions + if (item.displayConditions?.root) { + item.displayConditions.root.updateItemKeyReferences(oldFullKey, newFullKey); + } + if (item.displayConditions?.components) { + for (const expression of Object.values(item.displayConditions.components)) { + expression?.updateItemKeyReferences(oldFullKey, newFullKey); + } + } + + // Update template values + if (item.templateValues) { + for (const templateValue of Object.values(item.templateValues)) { + templateValue.expression?.updateItemKeyReferences(oldFullKey, newFullKey); + } + } + + // Update disabled conditions + if (item.disabledConditions?.components) { + for (const expression of Object.values(item.disabledConditions.components)) { + expression?.updateItemKeyReferences(oldFullKey, newFullKey); + } + } + + // Update validations + if (item.validations) { + for (const expression of Object.values(item.validations)) { + expression?.updateItemKeyReferences(oldFullKey, newFullKey); + } + } + } + } + + initNewItem(target: { + parentKey: string; + index?: number; + }, + itemType: SurveyItemType, + itemKey: string, + ) { + + + let newItem: SurveyItem; + const newItemKey = new SurveyItemKey(itemKey, target.parentKey); + // check if the item key is already in the survey + if (this._survey.surveyItems[newItemKey.fullKey]) { + throw new Error(`Item with key '${itemKey}' already exists`); + } + + switch (itemType) { + case SurveyItemType.Group: + newItem = new GroupItem(newItemKey.fullKey); + break; + case SurveyItemType.SingleChoiceQuestion: + newItem = new SingleChoiceQuestionItem(newItemKey.fullKey); + break; + // TODO: add init for other item types + + default: + throw new Error(`Unsupported item type: ${itemType}`); + } + + this.commitIfNeeded(); + this.addItem(target, newItem, new SurveyItemTranslations()) + + + this.commit(`Added new item`); + } + + addItem(target: { + parentKey: string; + index?: number; + } | undefined, + item: SurveyItem, + content: SurveyItemTranslations + ) { + this.commitIfNeeded(); + + // Find the parent group item + let parentGroup: GroupItem; + + if (!target) { + // If no target provided, add to root + // A root item has no parent (parentFullKey is undefined or empty) and is a single segment + const rootKey = Object.keys(this._survey.surveyItems).find(key => { + const surveyItem = this._survey.surveyItems[key]; + const isRootItem = surveyItem.key.keyParts.length === 1; + return isRootItem && surveyItem.itemType === SurveyItemType.Group; + }); + + if (!rootKey) { + throw new Error('No root group found in survey'); + } + + parentGroup = this._survey.surveyItems[rootKey] as GroupItem; + } else { + // Find the target parent group + const targetItem = this._survey.surveyItems[target.parentKey]; + + if (!targetItem) { + throw new Error(`Parent item with key '${target.parentKey}' not found`); + } + + if (targetItem.itemType !== SurveyItemType.Group) { + throw new Error(`Parent item '${target.parentKey}' is not a group item`); + } + + parentGroup = targetItem as GroupItem; + } + + // Initialize items array if it doesn't exist + if (!parentGroup.items) { + parentGroup.items = []; + } + + // Determine insertion index + let insertIndex: number; + if (target?.index !== undefined) { + // Insert at specified index, or at end if index is larger than array length + insertIndex = Math.min(target.index, parentGroup.items.length); + } else { + // Insert at the end + insertIndex = parentGroup.items.length; + } + + // Add the item to the survey items collection + this._survey.surveyItems[item.key.fullKey] = item; + + // Add the item key to the parent group's items array + parentGroup.items.splice(insertIndex, 0, item.key.fullKey); + + // Update translations in the survey + this._survey.translations.setItemTranslations(item.key.fullKey, content); + + // Mark as modified (uncommitted change) + this.commit(`Added ${item.key.fullKey}`); + } + + // Remove an item from the survey + removeItem(itemKey: string, ignoreCommit: boolean = false): boolean { + if (!ignoreCommit) { + this.commitIfNeeded(); + } + + const item = this._survey.surveyItems[itemKey]; + if (!item) { + return false; + } + + // Find parent group and remove from its items array + const parentKey = item.key.parentFullKey; + if (parentKey) { + const parentItem = this._survey.surveyItems[parentKey]; + if (parentItem && parentItem.itemType === SurveyItemType.Group) { + const parentGroup = parentItem as GroupItem; + if (parentGroup.items) { + const index = parentGroup.items.indexOf(itemKey); + if (index > -1) { + parentGroup.items.splice(index, 1); + } + } + } + } else { + throw new Error(`Item with key '${itemKey}' is the root item`); + } + + // Remove from survey items + delete this._survey.surveyItems[itemKey]; + + // Remove translations + this._survey.translations?.onItemDeleted(itemKey); + + if (item.itemType === SurveyItemType.Group) { + for (const childKey of (item as GroupItem).items || []) { + this.removeItem(childKey, true); + } + } + + if (!ignoreCommit) { + this.commit(`Removed ${itemKey}`); + } + return true; + } + + // Move an item to a different position + moveItem(itemKey: string, newTarget: { + parentKey: string; + index?: number; + }): boolean { + this.commitIfNeeded(); + + // Check if item exists + const item = this._survey.surveyItems[itemKey]; + if (!item) { + throw new Error(`Item with key '${itemKey}' not found`); + } + + // Check if new target exists and is a group + const targetItem = this._survey.surveyItems[newTarget.parentKey]; + if (!targetItem) { + throw new Error(`Target parent with key '${newTarget.parentKey}' not found`); + } + + if (targetItem.itemType !== SurveyItemType.Group) { + throw new Error(`Target parent '${newTarget.parentKey}' is not a group item`); + } + + // Check if new target is not a child of the current item (prevent circular reference) + if (this.isDescendantOf(newTarget.parentKey, itemKey)) { + throw new Error(`Cannot move item '${itemKey}' to its descendant '${newTarget.parentKey}'`); + } + + // If the item is already in the target parent + const currentParentKey = item.key.parentFullKey; + if (currentParentKey === newTarget.parentKey) { + throw new Error(`Item '${itemKey}' is already in the target parent '${newTarget.parentKey}'`); + } + + // Remove item from current parent's items array + if (currentParentKey) { + const currentParentItem = this._survey.surveyItems[currentParentKey]; + if (currentParentItem && currentParentItem.itemType === SurveyItemType.Group) { + const currentParentGroup = currentParentItem as GroupItem; + if (currentParentGroup.items) { + const index = currentParentGroup.items.indexOf(itemKey); + if (index > -1) { + currentParentGroup.items.splice(index, 1); + } + } + } + } + + // Create new key with new parent + const itemKeyObject = SurveyItemKey.fromFullKey(itemKey); + const newItemKey = new SurveyItemKey(itemKeyObject.itemKey, newTarget.parentKey); + + // Use onItemKeyChanged to rename the item and its subtree (this updates all references) + this.onItemKeyChanged(itemKey, newItemKey.fullKey, true); + + // Add item to new parent's items array + const targetGroup = targetItem as GroupItem; + if (!targetGroup.items) { + targetGroup.items = []; + } + + const insertIndex = newTarget.index !== undefined ? + Math.min(newTarget.index, targetGroup.items.length) : + targetGroup.items.length; + + targetGroup.items.splice(insertIndex, 0, newItemKey.fullKey); + + this.commit(`Moved ${itemKey} to ${newTarget.parentKey}`); + return true; + } + + // Helper method to check if targetKey is a descendant of ancestorKey + private isDescendantOf(targetKey: string, ancestorKey: string): boolean { + if (targetKey === ancestorKey || targetKey.startsWith(ancestorKey + '.')) { + return true; + } + return false; + } + + onItemKeyChanged(oldFullKey: string, newFullKey: string, skipCommit: boolean = false): void { + if (!skipCommit) { + this.commitIfNeeded(); + } + + // if new key already exists, throw an error + if (this._survey.surveyItems[newFullKey]) { + throw new Error(`Item with key '${newFullKey}' already exists. Cannot rename ${oldFullKey} to ${newFullKey}`); + } + + const item = this._survey.surveyItems[oldFullKey]; + if (!item) { + throw new Error(`Item with key '${oldFullKey}' not found`); + } + + // update parent's items array + const parentKey = item.key.parentFullKey; + if (parentKey) { + // Try to find parent in the current survey items (it might have been renamed already) + let parentItem = this._survey.surveyItems[parentKey] as GroupItem; + + // If parent is not found at the original key, it might have been renamed + // Check if this is a recursive call by looking for a renamed parent + if (!parentItem) { + for (const [_key, surveyItem] of Object.entries(this._survey.surveyItems)) { + if (surveyItem.itemType === SurveyItemType.Group) { + const groupItem = surveyItem as GroupItem; + if (groupItem.items?.includes(oldFullKey)) { + parentItem = groupItem; + break; + } + } + } + } + + if (parentItem?.items) { + const index = parentItem.items.indexOf(oldFullKey); + if (index > -1) { + parentItem.items[index] = newFullKey; + } + } + } + + // Update the item's key + item.onItemKeyChanged(newFullKey); + + // Move the item in the surveyItems dictionary + this._survey.surveyItems[newFullKey] = item; + delete this._survey.surveyItems[oldFullKey]; + + this._survey.translations.onItemKeyChanged(oldFullKey, newFullKey); + if (item.itemType === SurveyItemType.Group) { + for (const childKey of (item as GroupItem).items || []) { + const oldChildKey = SurveyItemKey.fromFullKey(childKey); + const newChildKey = new SurveyItemKey(oldChildKey.itemKey, newFullKey); + + this.onItemKeyChanged(childKey, newChildKey.fullKey, true); + } + } + + // Update references to the item in other items (e.g., expressions) + this.updateItemKeyReferencesInSurvey(oldFullKey, newFullKey); + + if (!skipCommit) { + this.commit(`Renamed ${oldFullKey} to ${newFullKey}`); + } else { + this.markAsModified(); + } + } + + onComponentKeyChanged(itemKey: string, oldKey: string, newKey: string): void { + this.commitIfNeeded(); + + const item = this._survey.surveyItems[itemKey]; + if (!item) { + throw new Error(`Item with key '${itemKey}' not found`); + } + + // Find and update the component in the item + this._survey.surveyItems[itemKey].onComponentKeyChanged(oldKey, newKey); + this._survey.translations.onComponentKeyChanged(itemKey, oldKey, newKey); + + this.commit(`Renamed component ${oldKey} to ${newKey} in ${itemKey}`); + } + + // TODO: add also to update component translations (updating part of the item) + // Update item translations + updateItemTranslations(itemKey: string, updatedContent?: SurveyItemTranslations): boolean { + const item = this._survey.surveyItems[itemKey]; + if (!item) { + throw new Error(`Item with key '${itemKey}' not found`); + } + + this._survey.translations.setItemTranslations(itemKey, updatedContent); + + this.markAsModified(); + return true; + } + + deleteComponent(itemKey: string, componentKey: string): void { + this.commitIfNeeded(); + + const item = this._survey.surveyItems[itemKey]; + if (!item) { + throw new Error(`Item with key '${itemKey}' not found`); + } + + item.onComponentDeleted?.(componentKey); + + // remove translations: + this._survey.translations?.onComponentDeleted(itemKey, componentKey); + + this.commit(`Deleted component ${componentKey} from ${itemKey}`); + } +} diff --git a/src/survey-editor/survey-item-editors.ts b/src/survey-editor/survey-item-editors.ts new file mode 100644 index 0000000..cc727b5 --- /dev/null +++ b/src/survey-editor/survey-item-editors.ts @@ -0,0 +1,311 @@ +import { SurveyItemKey } from "../survey/item-component-key"; +import { SurveyEditor } from "./survey-editor"; +import { MultipleChoiceQuestionItem, QuestionItem, SingleChoiceQuestionItem, SurveyItem, SurveyItemType } from "../survey/items"; +import { DisplayComponentEditor, ScgMcgOptionBaseEditor } from "./component-editor"; +import { DisplayComponent, ItemComponent, ItemComponentType, ScgMcgOption, ScgMcgOptionBase, ScgMcgOptionTypes } from "../survey"; +import { Content } from "../survey/utils/content"; +import { SurveyItemTranslations } from "../survey/utils"; +import { Expression, TemplateValueDefinition } from "../expressions"; + + + +export abstract class SurveyItemEditor { + protected readonly _editor: SurveyEditor; + protected _itemKeyAtOpen: SurveyItemKey; + protected _type: SurveyItemType; + protected _currentItem: SurveyItem; + + constructor(editor: SurveyEditor, itemFullKey: string, type: SurveyItemType) { + this._editor = editor; + this._itemKeyAtOpen = SurveyItemKey.fromFullKey(itemFullKey); + this._type = type; + + if (!this._editor.survey.surveyItems[itemFullKey]) { + throw new Error(`Item ${itemFullKey} not found in survey`); + } + + if (!this._editor.survey.surveyItems[itemFullKey].itemType || this._editor.survey.surveyItems[itemFullKey].itemType !== this._type) { + throw new Error(`Item ${itemFullKey} is not a ${this._type}`); + } + + this._currentItem = this._editor.survey.surveyItems[itemFullKey]; + } + + get editor(): SurveyEditor { + return this._editor; + } + + deleteComponent(component: ItemComponent): void { + this._editor.deleteComponent(this._currentItem.key.fullKey, component.key.fullKey); + } + + updateComponentTranslations(target: { + componentFullKey: string, + contentKey?: string + }, locale: string, translation?: Content): void { + const currentTranslations = this.editor.survey.getItemTranslations(this._currentItem.key.fullKey) ?? new SurveyItemTranslations(); + const translationKey = `${target.componentFullKey}${target.contentKey ? '.' + target.contentKey : ''}`; + + currentTranslations.setContent(locale, translationKey, translation); + + this.editor.updateItemTranslations(this._currentItem.key.fullKey, currentTranslations); + } + + setDisplayCondition(condition: Expression | undefined, componentFullKey?: string): void { + this.editor.commitIfNeeded(); + if (!condition) { + // remove condition + if (componentFullKey) { + delete this._currentItem.displayConditions?.components?.[componentFullKey]; + } else { + delete this._currentItem.displayConditions?.root; + } + } else { + // add condition + if (!this._currentItem.displayConditions) { + this._currentItem.displayConditions = {}; + } + if (componentFullKey) { + if (!this._currentItem.displayConditions?.components) { + this._currentItem.displayConditions.components = {}; + } + this._currentItem.displayConditions.components[componentFullKey] = condition; + } else { + if (!this._currentItem.displayConditions) { + this._currentItem.displayConditions = {}; + } + this._currentItem.displayConditions.root = condition; + } + } + this._editor.commit(`Set display condition for ${this._currentItem.key.fullKey}`); + } + + getDisplayCondition(componentFullKey?: string): Expression | undefined { + if (componentFullKey) { + return this._currentItem.displayConditions?.components?.[componentFullKey]?.clone(); + } else { + return this._currentItem.displayConditions?.root?.clone(); + } + } + + + setTemplateValue(templateValueKey: string, expression: TemplateValueDefinition | undefined): void { + this._editor.commitIfNeeded(); + if (!expression) { + delete this._currentItem.templateValues?.[templateValueKey]; + } else { + if (!this._currentItem.templateValues) { + this._currentItem.templateValues = {}; + } + this._currentItem.templateValues[templateValueKey] = expression; + } + this._editor.commit(`Set template value for ${this._currentItem.key.fullKey}`); + } + + getTemplateValue(templateValueKey: string): Expression | undefined { + return this._currentItem.templateValues?.[templateValueKey]?.expression?.clone(); + } + + getSiblingKeys(): SurveyItemKey[] { + const parentKey = this._currentItem.key.parentFullKey; + const currentFullKey = this._currentItem.key.fullKey; + + // Find all items with the same parent key (excluding current item) + const siblingKeys: SurveyItemKey[] = []; + + for (const itemFullKey of Object.keys(this._editor.survey.surveyItems)) { + const item = this._editor.survey.surveyItems[itemFullKey]; + + // Check if this item has the same parent and is not the current item + if (item.key.parentFullKey === parentKey && item.key.fullKey !== currentFullKey) { + siblingKeys.push(item.key); + } + } + + return siblingKeys; + } + + changeItemKey(newItemKey: string): void { + // Validate that newItemKey doesn't contain dots + if (newItemKey.includes('.')) { + throw new Error('Item key must not contain a dot (.)'); + } + + // If the new key is the same as current key, do nothing + if (this._currentItem.key.itemKey === newItemKey) { + return; + } + + // Check if a sibling with the same key already exists + const siblingKeys = this.getSiblingKeys(); + const siblingKeyExists = siblingKeys.some(siblingKey => siblingKey.itemKey === newItemKey); + + if (siblingKeyExists) { + throw new Error(`A sibling item with key '${newItemKey}' already exists`); + } + + // Construct the new full key + const currentParentKey = this._currentItem.key.parentFullKey; + const newFullKey = currentParentKey ? `${currentParentKey}.${newItemKey}` : newItemKey; + + // Call the editor's key changing method + this._editor.onItemKeyChanged(this._currentItem.key.fullKey, newFullKey); + + // Update our reference to the current item + this._currentItem = this._editor.survey.surveyItems[newFullKey]; + } + + changeComponentKey(oldComponentKey: string, newComponentKey: string): void { + this._editor.onComponentKeyChanged(this._currentItem.key.fullKey, oldComponentKey, newComponentKey); + } + + updateItemMetadata(metadata?: { [key: string]: string }): void { + this._editor.commitIfNeeded(); + this._currentItem.metadata = metadata; + this._editor.commit(`Updated metadata for ${this._currentItem.key.fullKey}`); + } + + abstract convertToType(type: SurveyItemType): void; +} + +export class GenericSurveyItemEditor extends SurveyItemEditor { + constructor(editor: SurveyEditor, itemFullKey: string, type: SurveyItemType) { + super(editor, itemFullKey, type); + } + + convertToType(type: SurveyItemType): void { + throw new Error(`use type specific editor to convert to ${type}`); + } +} + +export abstract class QuestionEditor extends SurveyItemEditor { + protected _currentItem: QuestionItem; + + constructor(editor: SurveyEditor, itemFullKey: string, type: SurveyItemType.SingleChoiceQuestion | SurveyItemType.MultipleChoiceQuestion) { + super(editor, itemFullKey, type); + this._currentItem = this._editor.survey.surveyItems[itemFullKey] as QuestionItem; + } + + get title(): DisplayComponentEditor { + if (!this._currentItem.header?.title) { + return new DisplayComponentEditor(this, new DisplayComponent(ItemComponentType.Text, 'title', undefined, this._currentItem.key.fullKey)) + } + return new DisplayComponentEditor(this, this._currentItem.header.title); + } + + get subtitle(): DisplayComponentEditor { + if (!this._currentItem.header?.subtitle) { + return new DisplayComponentEditor(this, new DisplayComponent(ItemComponentType.Text, 'subtitle', undefined, this._currentItem.key.fullKey)) + } + return new DisplayComponentEditor(this, this._currentItem.header.subtitle); + } + + setDisableCondition(condition: Expression | undefined, componentFullKey: string): void { + this._editor.commitIfNeeded(); + if (!condition) { + delete this._currentItem.disabledConditions?.components?.[componentFullKey]; + } else { + if (!this._currentItem.disabledConditions) { + this._currentItem.disabledConditions = {}; + } + if (!this._currentItem.disabledConditions?.components) { + this._currentItem.disabledConditions.components = {}; + } + this._currentItem.disabledConditions.components[componentFullKey] = condition; + } + this._editor.commit(`Set disable condition for ${this._currentItem.key.fullKey} ${componentFullKey}`); + } + + setValidation(validationKey: string, expression: Expression | undefined): void { + this._editor.commitIfNeeded(); + if (!expression) { + delete this._currentItem.validations?.[validationKey]; + } else { + if (!this._currentItem.validations) { + this._currentItem.validations = {}; + } + this._currentItem.validations[validationKey] = expression; + } + this._editor.commit(`Set validation for ${this._currentItem.key.fullKey} ${validationKey}`); + } + + getValidation(validationKey: string): Expression | undefined { + return this._currentItem.validations?.[validationKey]?.clone(); + } + + getDisableCondition(componentFullKey: string): Expression | undefined { + return this._currentItem.disabledConditions?.components?.[componentFullKey]?.clone(); + } +} + +/** + * Single choice question and multiple choice question are very similar things, this is the base class for them. + */ +abstract class ScgMcgEditor extends QuestionEditor { + protected _currentItem!: SingleChoiceQuestionItem | MultipleChoiceQuestionItem; + + constructor(editor: SurveyEditor, itemFullKey: string, type: SurveyItemType.SingleChoiceQuestion | SurveyItemType.MultipleChoiceQuestion) { + super(editor, itemFullKey, type); + } + + get optionEditors(): Array { + return this._currentItem.responseConfig.items.map(option => ScgMcgOptionBaseEditor.fromOption(this, option)); + } + + addNewOption(optionKey: string, optionType: ScgMcgOptionTypes, index?: number): void { + if (!this.optionKeyAvailable(optionKey)) { + throw new Error(`Option key ${optionKey} already exists`); + } + + let option: ScgMcgOptionBase; + switch (optionType) { + case ItemComponentType.ScgMcgOption: + option = new ScgMcgOption(optionKey, this._currentItem.responseConfig.key.fullKey, this._currentItem.key.fullKey); + break; + default: + throw new Error(`Unsupported option type: ${optionType}`); + } + this.addExistingOption(option, index); + } + + addExistingOption(option: ScgMcgOptionBase, index?: number): void { + if (index !== undefined && index >= 0) { + this._currentItem.responseConfig.items.splice(index, 0, option); + } else { + this._currentItem.responseConfig.items.push(option); + } + } + + optionKeyAvailable(optionKey: string): boolean { + return !this._currentItem.responseConfig.items.some(option => option.key.componentKey === optionKey); + } + + swapOptions(activeIndex: number, overIndex: number): void { + const newOrder = [...this._currentItem.responseConfig.items]; + newOrder.splice(activeIndex, 1); + newOrder.splice(overIndex, 0, this._currentItem.responseConfig.items[activeIndex]); + this._currentItem.responseConfig.items = newOrder; + } +} + +export class SingleChoiceQuestionEditor extends ScgMcgEditor { + protected _currentItem: SingleChoiceQuestionItem; + + constructor(editor: SurveyEditor, itemFullKey: string) { + super(editor, itemFullKey, SurveyItemType.SingleChoiceQuestion); + this._currentItem = this._editor.survey.surveyItems[itemFullKey] as SingleChoiceQuestionItem; + } + + convertToType(type: SurveyItemType): void { + switch (type) { + case SurveyItemType.SingleChoiceQuestion: + return; + case SurveyItemType.MultipleChoiceQuestion: + // TODO: implement + console.log('convert to multiple choice question'); + return; + default: + throw new Error(`Cannot convert ${this._type} to ${type}`); + } + } +} diff --git a/src/survey-editor/undo-redo.ts b/src/survey-editor/undo-redo.ts new file mode 100644 index 0000000..38337c0 --- /dev/null +++ b/src/survey-editor/undo-redo.ts @@ -0,0 +1,275 @@ +import { JsonSurvey } from "../survey/survey-file-schema"; +import { structuredCloneMethod } from "../utils"; + +export interface UndoRedoConfig { + maxTotalMemoryMB: number; + minHistorySize: number; + maxHistorySize: number; +} + +interface HistoryEntry { + survey: JsonSurvey; + timestamp: number; + description: string; + memorySize: number; +} + +// Memory calculation utilities +class MemoryCalculator { + private static encoder = new TextEncoder(); + + static calculateSize(obj: object): number { + const jsonString = JSON.stringify(obj); + return this.encoder.encode(jsonString).length; + } + + static formatBytes(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(1)} ${units[unitIndex]}`; + } +} + + +export class SurveyEditorUndoRedo { + private history: HistoryEntry[] = []; + private currentIndex: number = -1; + private _config: UndoRedoConfig; + + constructor(initialSurvey: JsonSurvey, config: Partial = {}) { + this._config = { + maxTotalMemoryMB: 50, + minHistorySize: 10, + maxHistorySize: 200, + ...config + }; + + this.saveSnapshot(initialSurvey, 'Initial state'); + } + + + private saveSnapshot(survey: JsonSurvey, description: string): void { + const memorySize = MemoryCalculator.calculateSize(survey); + + // Remove any history after current index + this.history = this.history.slice(0, this.currentIndex + 1); + + // Add new snapshot + this.history.push({ + survey: structuredCloneMethod(survey), + timestamp: Date.now(), + description, + memorySize + }); + + this.currentIndex++; + + // Clean up history based on memory usage + this.cleanupHistory(); + } + + private cleanupHistory(): void { + let totalMemory = this.getTotalMemoryUsage(); + const maxMemoryBytes = this._config.maxTotalMemoryMB * 1024 * 1024; + + // Remove oldest snapshots while preserving minimum + while (this.history.length > this._config.minHistorySize && + (totalMemory > maxMemoryBytes || this.history.length > this._config.maxHistorySize)) { + + const removedSnapshot = this.history.shift(); + this.currentIndex--; + + if (removedSnapshot) { + totalMemory -= removedSnapshot.memorySize; + } + } + } + + private getTotalMemoryUsage(): number { + return this.history.reduce((total, entry) => total + entry.memorySize, 0); + } + + // Commit a change to history + commit(survey: JsonSurvey, description: string): void { + this.saveSnapshot(survey, description); + } + + // Get current committed state + getCurrentState(): JsonSurvey { + if (this.currentIndex < 0 || this.currentIndex >= this.history.length) { + throw new Error('Invalid history state'); + } + return structuredCloneMethod(this.history[this.currentIndex].survey); + } + + undo(): JsonSurvey | null { + if (!this.canUndo()) return null; + + this.currentIndex--; + return this.getCurrentState(); + } + + redo(): JsonSurvey | null { + if (!this.canRedo()) return null; + + this.currentIndex++; + return this.getCurrentState(); + } + + canUndo(): boolean { + return this.currentIndex > 0; + } + + canRedo(): boolean { + return this.currentIndex < this.history.length - 1; + } + + getUndoDescription(): string | null { + if (!this.canUndo()) return null; + return this.history[this.currentIndex].description; + } + + getRedoDescription(): string | null { + if (!this.canRedo()) return null; + return this.history[this.currentIndex + 1].description; + } + + getMemoryUsage(): { totalMB: number; entries: number } { + return { + totalMB: this.getTotalMemoryUsage() / (1024 * 1024), + entries: this.history.length + }; + } + + getConfig(): UndoRedoConfig { + return { ...this._config }; + } + + /** + * Get the full history list with metadata + */ + getHistory(): Array<{ + index: number; + description: string; + timestamp: number; + memorySize: number; + isCurrent: boolean; + }> { + return this.history.map((entry, index) => ({ + index, + description: entry.description, + timestamp: entry.timestamp, + memorySize: entry.memorySize, + isCurrent: index === this.currentIndex + })); + } + + /** + * Get the current index in the history + */ + getCurrentIndex(): number { + return this.currentIndex; + } + + /** + * Get the total number of history entries + */ + getHistoryLength(): number { + return this.history.length; + } + + /** + * Jump to a specific index in the history (can go forward or backward) + * @param targetIndex The index to jump to + * @returns The survey state at the target index, or null if invalid + */ + jumpToIndex(targetIndex: number): JsonSurvey | null { + if (targetIndex < 0 || targetIndex >= this.history.length || targetIndex === this.currentIndex) { + return null; + } + + this.currentIndex = targetIndex; + return this.getCurrentState(); + } + + /** + * Check if we can jump to a specific index + */ + canJumpToIndex(targetIndex: number): boolean { + return targetIndex >= 0 && targetIndex < this.history.length && targetIndex !== this.currentIndex; + } + + /** + * Serialize the undo/redo state to JSON + * @returns A JSON-serializable object containing the complete state + */ + toJSON(): { + history: Array; + currentIndex: number; + config: UndoRedoConfig; + } { + return { + history: this.history.map(entry => ({ + survey: entry.survey, + timestamp: entry.timestamp, + description: entry.description, + memorySize: entry.memorySize + })), + currentIndex: this.currentIndex, + config: { ...this._config } + }; + } + + /** + * Create a new SurveyEditorUndoRedo instance from JSON data + * @param jsonData The serialized undo/redo state + * @returns A new SurveyEditorUndoRedo instance with the restored state + */ + static fromJSON(jsonData: { + history: Array<{ + survey: JsonSurvey; + timestamp: number; + description: string; + memorySize: number; + }>; + currentIndex: number; + config: UndoRedoConfig; + }): SurveyEditorUndoRedo { + if (!jsonData.history || !Array.isArray(jsonData.history) || jsonData.history.length === 0) { + throw new Error('Invalid JSON data: history array is required and must not be empty'); + } + + if (typeof jsonData.currentIndex !== 'number' || + jsonData.currentIndex < 0 || + jsonData.currentIndex >= jsonData.history.length) { + throw new Error('Invalid JSON data: currentIndex must be a valid index within the history array'); + } + + if (!jsonData.config) { + throw new Error('Invalid JSON data: config is required'); + } + + // Create a new instance with the first survey and config + const instance = new SurveyEditorUndoRedo(jsonData.history[0].survey, jsonData.config); + + // Clear the default initial state and restore the full history + instance.history = jsonData.history.map(entry => ({ + survey: structuredCloneMethod(entry.survey), + timestamp: entry.timestamp, + description: entry.description, + memorySize: entry.memorySize + })); + + instance.currentIndex = jsonData.currentIndex; + + return instance; + } + +} diff --git a/src/survey/components/index.ts b/src/survey/components/index.ts new file mode 100644 index 0000000..2e35981 --- /dev/null +++ b/src/survey/components/index.ts @@ -0,0 +1,2 @@ +export * from './survey-item-component'; +export * from './types'; diff --git a/src/survey/components/survey-item-component.ts b/src/survey/components/survey-item-component.ts new file mode 100644 index 0000000..e6e0f71 --- /dev/null +++ b/src/survey/components/survey-item-component.ts @@ -0,0 +1,366 @@ +import { ItemComponentKey } from "../item-component-key"; +import { JsonItemComponent } from "../survey-file-schema"; +import { ExpectedValueType } from "../utils"; +import { ValueReference, ValueReferenceMethod } from "../utils/value-reference"; +import { DisplayComponentTypes, ItemComponentType, ResponseConfigComponentTypes, ScgMcgOptionTypes } from "./types"; + + +// ======================================== +// ITEM COMPONENT BASE CLASS +// ======================================== +export abstract class ItemComponent { + key!: ItemComponentKey; + componentType!: ItemComponentType; + + styles?: { + classNames?: string | { + [key: string]: string; + } + } + + + constructor( + compKey: string, + parentFullKey: string | undefined = undefined, + componentType: ItemComponentType, + parentItemKey: string | undefined = undefined, + ) { + this.key = new ItemComponentKey( + compKey, + parentFullKey, + parentItemKey ?? '', + ); + this.componentType = componentType; + } + + abstract toJson(): JsonItemComponent + + onSubComponentDeleted?(componentKey: string): void; + + onItemKeyChanged(newFullKey: string): void { + this.key.setParentItemKey(newFullKey); + } + + onParentComponentKeyChanged(newParentFullKey: string): void { + this.key.setParentComponentFullKey(newParentFullKey); + } + + onComponentKeyChanged(newComponentKey: string): void { + this.key = ItemComponentKey.fromFullKey(newComponentKey, this.key.parentItemKey.fullKey); + } +} + +const initDisplayComponentBasedOnType = (json: JsonItemComponent, parentFullKey: string | undefined = undefined, parentItemKey: string): DisplayComponent => { + const componentKey = ItemComponentKey.fromFullKey(json.key, parentItemKey).componentKey; + + switch (json.type) { + case ItemComponentType.Text: { + const textComp = new TextComponent(componentKey, parentFullKey, parentItemKey); + textComp.styles = json.styles; + return textComp; + } + case ItemComponentType.Markdown: { + const markdownComp = new MarkdownComponent(componentKey, parentFullKey, parentItemKey); + markdownComp.styles = json.styles; + return markdownComp; + } + case ItemComponentType.Info: { + const infoComp = new InfoComponent(componentKey, parentFullKey, parentItemKey); + infoComp.styles = json.styles; + return infoComp; + } + case ItemComponentType.Warning: { + const warningComp = new WarningComponent(componentKey, parentFullKey, parentItemKey); + warningComp.styles = json.styles; + return warningComp; + } + case ItemComponentType.Error: { + const errorComp = new ErrorComponent(componentKey, parentFullKey, parentItemKey); + errorComp.styles = json.styles; + return errorComp; + } + default: + throw new Error(`Unsupported display component type for initialization: ${json.type}`); + } +} + + +/** + * Group component + */ +export abstract class GroupComponent extends ItemComponent { + items?: Array; + shuffleItems?: boolean; + + constructor(type: ItemComponentType, compKey: string, parentFullKey: string | undefined = undefined, parentItemKey: string | undefined = undefined) { + super( + compKey, + parentFullKey, + type, + parentItemKey, + ); + } + + onSubComponentDeleted(componentKey: string): void { + this.items = this.items?.filter(item => item.key.fullKey !== componentKey); + this.items?.forEach(item => { + if (componentKey.startsWith(item.key.fullKey)) { + item.onSubComponentDeleted?.(componentKey); + } + }); + } + + onItemKeyChanged(newFullKey: string): void { + super.onItemKeyChanged(newFullKey); + this.items?.forEach(item => { + item.onItemKeyChanged(newFullKey); + }); + } + + onParentComponentKeyChanged(newParentFullKey: string): void { + super.onParentComponentKeyChanged(newParentFullKey); + this.items?.forEach(item => { + item.onParentComponentKeyChanged(this.key.fullKey); + }); + } + + onComponentKeyChanged(newComponentKey: string): void { + super.onComponentKeyChanged(newComponentKey); + const newFullKey = this.key.fullKey; + + // Update all nested components to have the new parent full key + this.items?.forEach(item => { + item.onParentComponentKeyChanged(newFullKey); + }); + } +} + + +// ======================================== +// DISPLAY COMPONENTS +// ======================================== +export class DisplayComponent extends ItemComponent { + componentType!: DisplayComponentTypes; + + constructor( + type: DisplayComponentTypes, + compKey: string, parentFullKey: string | undefined = undefined, parentItemKey: string | undefined = undefined) { + super( + compKey, + parentFullKey, + type, + parentItemKey, + ); + } + + static fromJson(json: JsonItemComponent, parentFullKey: string | undefined = undefined, parentItemKey: string): DisplayComponent { + return initDisplayComponentBasedOnType(json, parentFullKey, parentItemKey); + } + + toJson(): JsonItemComponent { + return { + key: this.key.fullKey, + type: this.componentType, + styles: this.styles, + } + } +} + +export class TextComponent extends DisplayComponent { + componentType: ItemComponentType.Text = ItemComponentType.Text; + + constructor(compKey: string, parentFullKey: string | undefined = undefined, parentItemKey: string | undefined = undefined) { + super(ItemComponentType.Text, compKey, parentFullKey, parentItemKey); + } +} + +export class MarkdownComponent extends DisplayComponent { + componentType: ItemComponentType.Markdown = ItemComponentType.Markdown; + + constructor(compKey: string, parentFullKey: string | undefined = undefined, parentItemKey: string | undefined = undefined) { + super(ItemComponentType.Markdown, compKey, parentFullKey, parentItemKey); + } +} + +export class InfoComponent extends DisplayComponent { + componentType: ItemComponentType.Info = ItemComponentType.Info; + + constructor(compKey: string, parentFullKey: string | undefined = undefined, parentItemKey: string | undefined = undefined) { + super(ItemComponentType.Info, compKey, parentFullKey, parentItemKey); + } +} + +export class WarningComponent extends DisplayComponent { + componentType: ItemComponentType.Warning = ItemComponentType.Warning; + + constructor(compKey: string, parentFullKey: string | undefined = undefined, parentItemKey: string | undefined = undefined) { + super(ItemComponentType.Warning, compKey, parentFullKey, parentItemKey); + } +} + +export class ErrorComponent extends DisplayComponent { + componentType: ItemComponentType.Error = ItemComponentType.Error; + + constructor(compKey: string, parentFullKey: string | undefined = undefined, parentItemKey: string | undefined = undefined) { + super(ItemComponentType.Error, compKey, parentFullKey, parentItemKey); + } +} + +// ======================================== +// RESPONSE CONFIG COMPONENTS +// ======================================== + +export type ValueRefTypeLookup = { + [valueRefString: string]: ExpectedValueType; +} + +export abstract class ResponseConfigComponent extends ItemComponent { + constructor( + type: ResponseConfigComponentTypes, + compKey: string, + parentFullKey: string | undefined = undefined, + parentItemKey: string | undefined = undefined, + ) { + super(compKey, parentFullKey, type, parentItemKey); + } + + abstract toJson(): JsonItemComponent; + + abstract get valueReferences(): ValueRefTypeLookup; +} + + +// ======================================== +// SCG/MCG COMPONENTS +// ======================================== +export class ScgMcgChoiceResponseConfig extends GroupComponent { + componentType: ItemComponentType.SingleChoice = ItemComponentType.SingleChoice; + items: Array; + shuffleItems?: boolean; + + + constructor(compKey: string, parentFullKey: string | undefined = undefined, parentItemKey: string | undefined = undefined) { + super( + ItemComponentType.SingleChoice, + compKey, + parentFullKey, + parentItemKey, + ); + this.items = []; + } + + static fromJson(json: JsonItemComponent, parentFullKey: string | undefined = undefined, parentItemKey: string): ScgMcgChoiceResponseConfig { + // Extract component key from full key + const componentKey = ItemComponentKey.fromFullKey(json.key, parentItemKey).componentKey; + const singleChoice = new ScgMcgChoiceResponseConfig(componentKey, parentFullKey, parentItemKey); + singleChoice.items = json.items?.map(item => ScgMcgOptionBase.fromJson(item, singleChoice.key.fullKey, singleChoice.key.parentItemKey.fullKey)) ?? []; + singleChoice.styles = json.styles; + singleChoice.shuffleItems = json.properties?.shuffleItems as boolean | undefined; + return singleChoice; + } + + toJson(): JsonItemComponent { + return { + key: this.key.fullKey, + type: ItemComponentType.SingleChoice, + items: this.items.map(option => option.toJson()), + styles: this.styles, + properties: this.shuffleItems !== undefined ? { shuffleItems: this.shuffleItems } : undefined, + } + } + + get valueReferences(): ValueRefTypeLookup { + const subSlots = this.items?.reduce((acc, option) => { + const optionValueRefs = option.valueReferences; + Object.keys(optionValueRefs).forEach(key => { + acc[key] = optionValueRefs[key]; + }); + return acc; + }, {} as ValueRefTypeLookup) ?? {}; + + return { + ...subSlots, + [ValueReference.fromParts(this.key.parentItemKey, ValueReferenceMethod.get).toString()]: ExpectedValueType.String, + [ValueReference.fromParts(this.key.parentItemKey, ValueReferenceMethod.isDefined).toString()]: ExpectedValueType.Boolean, + } + } +} + +export abstract class ScgMcgOptionBase extends ItemComponent { + componentType!: ScgMcgOptionTypes; + + static fromJson(item: JsonItemComponent, parentFullKey: string | undefined = undefined, parentItemKey: string): ScgMcgOptionBase { + switch (item.type) { + case ItemComponentType.ScgMcgOption: + return ScgMcgOption.fromJson(item, parentFullKey, parentItemKey); + case ItemComponentType.ScgMcgOptionWithTextInput: + return ScgMcgOptionWithTextInput.fromJson(item, parentFullKey, parentItemKey); + + default: + throw new Error(`Unsupported item type for initialization: ${item.type}`); + } + } + + abstract get valueReferences(): ValueRefTypeLookup; +} + +export class ScgMcgOption extends ScgMcgOptionBase { + componentType: ItemComponentType.ScgMcgOption = ItemComponentType.ScgMcgOption; + + constructor(compKey: string, parentFullKey: string | undefined = undefined, parentItemKey: string | undefined = undefined) { + super(compKey, parentFullKey, ItemComponentType.ScgMcgOption, parentItemKey); + } + + static fromJson(json: JsonItemComponent, parentFullKey: string | undefined = undefined, parentItemKey: string): ScgMcgOption { + const componentKey = ItemComponentKey.fromFullKey(json.key, parentItemKey).componentKey; + const option = new ScgMcgOption(componentKey, parentFullKey, parentItemKey); + option.styles = json.styles; + return option; + } + + toJson(): JsonItemComponent { + return { + key: this.key.fullKey, + type: ItemComponentType.ScgMcgOption, + styles: this.styles, + } + } + + get valueReferences(): ValueRefTypeLookup { + // has no external value references + return {}; + } +} + +export class ScgMcgOptionWithTextInput extends ScgMcgOptionBase { + componentType: ItemComponentType.ScgMcgOptionWithTextInput = ItemComponentType.ScgMcgOptionWithTextInput; + + constructor(compKey: string, parentFullKey: string | undefined = undefined, parentItemKey: string | undefined = undefined) { + super(compKey, parentFullKey, ItemComponentType.ScgMcgOptionWithTextInput, parentItemKey); + } + + static fromJson(json: JsonItemComponent, parentFullKey: string | undefined = undefined, parentItemKey: string): ScgMcgOptionWithTextInput { + const componentKey = ItemComponentKey.fromFullKey(json.key, parentItemKey).componentKey; + const option = new ScgMcgOptionWithTextInput(componentKey, parentFullKey, parentItemKey); + option.styles = json.styles; + return option; + } + + toJson(): JsonItemComponent { + return { + key: this.key.fullKey, + type: ItemComponentType.ScgMcgOptionWithTextInput, + styles: this.styles, + } + } + + get valueReferences(): ValueRefTypeLookup { + return { + [ValueReference.fromParts(this.key.parentItemKey, ValueReferenceMethod.get, this.key).toString()]: ExpectedValueType.String, + [ValueReference.fromParts(this.key.parentItemKey, ValueReferenceMethod.isDefined, this.key).toString()]: ExpectedValueType.Boolean, + }; + } +} + + + diff --git a/src/survey/components/types.ts b/src/survey/components/types.ts new file mode 100644 index 0000000..36e32c9 --- /dev/null +++ b/src/survey/components/types.ts @@ -0,0 +1,49 @@ + +export enum ItemComponentType { + Text = 'text', + Markdown = 'markdown', + Info = 'info', + Warning = 'warning', + Error = 'error', + + // RESPONSE CONFIG COMPONENTS + SingleChoice = 'scg', + MultipleChoice = 'mcg', + + + // RESPONSE SUB COMPONENTS + ScgMcgOption = 'scgMcgOption', + ScgMcgOptionWithTextInput = 'scgMcgOptionWithTextInput', + ScgMcgOptionWithNumberInput = 'scgMcgOptionWithNumberInput', + ScgMcgOptionWithDateInput = 'scgMcgOptionWithDateInput', + ScgMcgOptionWithTimeInput = 'scgMcgOptionWithTimeInput', + ScgMcgOptionWithDropdown = 'scgMcgOptionWithDropdown', + ScgMcgOptionWithCloze = 'scgMcgOptionWithCloze', + +} + +export type DisplayComponentTypes = + | ItemComponentType.Text + | ItemComponentType.Markdown + | ItemComponentType.Info + | ItemComponentType.Warning + | ItemComponentType.Error + +export type ResponseConfigComponentTypes = + | ItemComponentType.SingleChoice + | ItemComponentType.MultipleChoice; +// TODO: Add more response config components + +// Union type for all ScgMcg option types +export type ScgMcgOptionTypes = + | ItemComponentType.ScgMcgOption + | ItemComponentType.ScgMcgOptionWithTextInput + | ItemComponentType.ScgMcgOptionWithNumberInput + | ItemComponentType.ScgMcgOptionWithDateInput + | ItemComponentType.ScgMcgOptionWithTimeInput + | ItemComponentType.ScgMcgOptionWithDropdown + | ItemComponentType.ScgMcgOptionWithCloze; + +export type GroupComponentTypes = + | ItemComponentType.SingleChoice + | ItemComponentType.MultipleChoice; \ No newline at end of file diff --git a/src/survey/index.ts b/src/survey/index.ts new file mode 100644 index 0000000..4a529f9 --- /dev/null +++ b/src/survey/index.ts @@ -0,0 +1,6 @@ +export * from './components'; +export * from './items'; +export * from './item-component-key'; +export * from './responses'; +export * from './survey'; +export * from './utils'; diff --git a/src/survey/item-component-key.ts b/src/survey/item-component-key.ts new file mode 100644 index 0000000..bce8075 --- /dev/null +++ b/src/survey/item-component-key.ts @@ -0,0 +1,150 @@ +abstract class Key { + protected _key: string; + protected _fullKey!: string; + protected _keyParts!: Array; + + protected _parentFullKey?: string; + protected _parentKey?: string; + + constructor(key: string, parentFullKey?: string) { + if (parentFullKey !== undefined && parentFullKey !== '') { + if (parentFullKey.startsWith('.')) { + throw new Error('Parent key must not start with a dot (.)'); + } + if (parentFullKey.endsWith('.')) { + throw new Error('Parent key must not end with a dot (.)'); + } + } + this._key = key; + this._parentFullKey = parentFullKey; + this.computeFullKey(); + this.computeParentKey(); + } + + get isRoot(): boolean { + return this._parentFullKey === undefined; + } + + get fullKey(): string { + return this._fullKey; + } + + get keyParts(): Array { + return this._keyParts; + } + + get parentFullKey(): string | undefined { + return this._parentFullKey; + } + + get parentKey(): string | undefined { + return this._parentKey; + } + + private validateKey(key: string): void { + if (key.trim() === '') { + throw new Error('Key cannot be empty'); + } + if (key.includes('.')) { + throw new Error('Key must not contain a dot (.)'); + } + } + + private computeFullKey(): void { + this._fullKey = `${this._parentFullKey ? this._parentFullKey + '.' : ''}${this._key}`; + this._keyParts = this._fullKey.split('.'); + } + + private computeParentKey(): void { + this._parentKey = this._parentFullKey ? this._parentFullKey.split('.').pop() : undefined; + } + + setParentFullKey(newFullKey: string | undefined): void { + this._parentFullKey = newFullKey; + this.computeParentKey(); + this.computeFullKey(); + } + + protected setKey(newKey: string): void { + this.validateKey(newKey); + this._key = newKey; + this.computeFullKey(); + } +} + + +/** + * SurveyItemKey stores the key of the item and the full key of the parent item. + * The full key can be used to identify the item in the survey. + * The item key can be used to identify the item within the parent item. + */ + +export class SurveyItemKey extends Key { + constructor(itemKey: string, parentFullKey?: string) { + if (itemKey.includes('.')) { + throw new Error('Item key must not contain a dot (.)'); + } + super(itemKey, parentFullKey); + } + + static fromFullKey(fullKey: string): SurveyItemKey { + const keyParts = fullKey.split('.'); + const itemKey = keyParts[keyParts.length - 1]; + const parentFullKey = keyParts.slice(0, -1).join('.'); + return new SurveyItemKey(itemKey, parentFullKey || undefined); + } + + get itemKey(): string { + return this._key; + } +} + + +/** + * ItemComponentKey stores the key of the component and the full key of the parent component and key of the survey item this component belongs to. + * The full key can be used to identify the component in the survey item. + * The component key can be used to identify the component within the parent component. + * The parent item key can be used to identify the survey item this component belongs to. + */ +export class ItemComponentKey extends Key { + private _parentItemKey: SurveyItemKey; + + constructor( + componentKey: string, + parentComponentFullKey: string | undefined, + parentItemFullKey: string, + ) { + if (componentKey.includes('.')) { + throw new Error('Component key must not contain a dot (.)'); + } + super(componentKey, parentComponentFullKey); + this._parentItemKey = SurveyItemKey.fromFullKey(parentItemFullKey); + } + + get componentKey(): string { + return this._key; + } + + get parentItemKey(): SurveyItemKey { + return this._parentItemKey; + } + + setParentItemKey(newFullKey: string): void { + this._parentItemKey = SurveyItemKey.fromFullKey(newFullKey); + } + + setComponentKey(newComponentKey: string): void { + this.setKey(newComponentKey); + } + + setParentComponentFullKey(newParentFullKey: string | undefined): void { + this.setParentFullKey(newParentFullKey); + } + + static fromFullKey(fullKey: string, itemFullKey: string): ItemComponentKey { + const keyParts = fullKey.split('.'); + const componentKey = keyParts[keyParts.length - 1]; + const parentComponentFullKey = keyParts.slice(0, -1).join('.'); + return new ItemComponentKey(componentKey, parentComponentFullKey, itemFullKey); + } +} diff --git a/src/survey/items/index.ts b/src/survey/items/index.ts new file mode 100644 index 0000000..3da674f --- /dev/null +++ b/src/survey/items/index.ts @@ -0,0 +1,3 @@ +export * from './survey-item'; +export * from './survey-item-json'; +export * from './types'; \ No newline at end of file diff --git a/src/survey/items/survey-item-json.ts b/src/survey/items/survey-item-json.ts new file mode 100644 index 0000000..fba81f1 --- /dev/null +++ b/src/survey/items/survey-item-json.ts @@ -0,0 +1,71 @@ +import { JsonExpression } from "../../expressions"; +import { JsonItemComponent } from "../survey-file-schema"; +import { JsonTemplateValue } from "../../expressions/template-value"; +import { ConfidentialMode, SurveyItemType } from "./types"; + + +export interface JsonSurveyItemBase { + itemType: string; + metadata?: { + [key: string]: string; + } + + templateValues?: { + [templateValueKey: string]: JsonTemplateValue; + }; + validations?: { + [validationKey: string]: JsonExpression | undefined; + }; + displayConditions?: { + root?: JsonExpression; + components?: { + [componentKey: string]: JsonExpression | undefined; + } + } + disabledConditions?: { + components?: { + [componentKey: string]: JsonExpression | undefined; + } + } +} + + +export interface JsonSurveyItemGroup extends JsonSurveyItemBase { + itemType: SurveyItemType.Group; + items?: Array; + shuffleItems?: boolean; +} + +export interface JsonSurveyDisplayItem extends JsonSurveyItemBase { + itemType: SurveyItemType.Display; + components: Array; +} + +export interface JsonSurveyPageBreakItem extends JsonSurveyItemBase { + itemType: SurveyItemType.PageBreak; +} + +export interface JsonSurveyEndItem extends JsonSurveyItemBase { + itemType: SurveyItemType.SurveyEnd; +} + +export interface JsonSurveyQuestionItem extends JsonSurveyItemBase { + header?: { + title?: JsonItemComponent; + subtitle?: JsonItemComponent; + helpPopover?: JsonItemComponent; + } + body?: { + topContent?: Array; + bottomContent?: Array; + } + footer?: JsonItemComponent; + confidentiality?: { + mode: ConfidentialMode; + mapToKey?: string; + } + + responseConfig: JsonItemComponent; +} + +export type JsonSurveyItem = JsonSurveyItemGroup | JsonSurveyDisplayItem | JsonSurveyPageBreakItem | JsonSurveyEndItem | JsonSurveyQuestionItem; diff --git a/src/survey/items/survey-item.ts b/src/survey/items/survey-item.ts new file mode 100644 index 0000000..70d188d --- /dev/null +++ b/src/survey/items/survey-item.ts @@ -0,0 +1,540 @@ +import { JsonSurveyDisplayItem, JsonSurveyEndItem, JsonSurveyItem, JsonSurveyItemGroup, JsonSurveyPageBreakItem, JsonSurveyQuestionItem } from './survey-item-json'; +import { SurveyItemKey } from '../item-component-key'; +import { TemplateValueDefinition, templateValuesFromJson, templateValuesToJson } from '../../expressions/template-value'; +import { Expression } from '../../expressions'; +import { DisabledConditions, disabledConditionsFromJson, disabledConditionsToJson, DisplayConditions, displayConditionsFromJson, displayConditionsToJson } from './utils'; +import { DisplayComponent, ItemComponent, TextComponent, ScgMcgChoiceResponseConfig } from '../components'; +import { ConfidentialMode, SurveyItemType } from './types'; +import { ReferenceUsage, ReferenceUsageType } from '../utils'; + + +// ======================================== +// SURVEY ITEM BASE CLASS +// ======================================== +export abstract class SurveyItem { + key!: SurveyItemKey; + itemType!: SurveyItemType; + metadata?: { + [key: string]: string; + } + + displayConditions?: DisplayConditions; + templateValues?: { + [templateValueKey: string]: TemplateValueDefinition; + } + disabledConditions?: DisabledConditions; + validations?: { + [validationKey: string]: Expression | undefined; + } + + constructor(itemFullKey: string, itemType: SurveyItemType) { + this.key = SurveyItemKey.fromFullKey(itemFullKey); + this.itemType = itemType; + } + + abstract toJson(): JsonSurveyItem + + abstract onComponentKeyChanged(oldKey: string, newKey: string): void; + onComponentDeleted?(componentFullKey: string): void; + onItemKeyChanged(newFullKey: string): void { + this.key = SurveyItemKey.fromFullKey(newFullKey); + } + + static fromJson(key: string, json: JsonSurveyItem): SurveyItem { + return initItemClassBasedOnType(key, json); + } + + getReferenceUsages(): ReferenceUsage[] { + const usages: ReferenceUsage[] = []; + + if (this.displayConditions) { + // root + for (const ref of this.displayConditions.root?.responseVariableRefs || []) { + usages.push({ + fullItemKey: this.key.fullKey, + usageType: ReferenceUsageType.displayConditions, + valueReference: ref, + }); + } + + // components + for (const [componentKey, expression] of Object.entries(this.displayConditions.components || {})) { + for (const ref of expression?.responseVariableRefs || []) { + usages.push({ + fullItemKey: this.key.fullKey, + fullComponentKey: componentKey, + usageType: ReferenceUsageType.displayConditions, + valueReference: ref, + }); + } + } + } + + if (this.templateValues) { + for (const [templateValueKey, templateValue] of Object.entries(this.templateValues)) { + for (const ref of templateValue.expression?.responseVariableRefs || []) { + usages.push({ + fullItemKey: this.key.fullKey, + fullComponentKey: templateValueKey, + usageType: ReferenceUsageType.templateValues, + valueReference: ref, + }); + } + } + } + + if (this.disabledConditions) { + for (const [componentKey, expression] of Object.entries(this.disabledConditions.components || {})) { + for (const ref of expression?.responseVariableRefs || []) { + usages.push({ + fullItemKey: this.key.fullKey, + fullComponentKey: componentKey, + usageType: ReferenceUsageType.disabledConditions, + valueReference: ref, + }); + } + } + } + + if (this.validations) { + for (const [validationKey, expression] of Object.entries(this.validations)) { + for (const ref of expression?.responseVariableRefs || []) { + usages.push({ + fullItemKey: this.key.fullKey, + fullComponentKey: validationKey, + usageType: ReferenceUsageType.validations, + valueReference: ref, + }); + } + } + } + + return usages; + } + +} + +const initItemClassBasedOnType = (key: string, json: JsonSurveyItem): SurveyItem => { + switch (json.itemType) { + case SurveyItemType.Group: + return GroupItem.fromJson(key, json as JsonSurveyItemGroup); + case SurveyItemType.Display: + return DisplayItem.fromJson(key, json as JsonSurveyDisplayItem); + case SurveyItemType.PageBreak: + return PageBreakItem.fromJson(key, json as JsonSurveyPageBreakItem); + case SurveyItemType.SurveyEnd: + return SurveyEndItem.fromJson(key, json as JsonSurveyEndItem); + case SurveyItemType.SingleChoiceQuestion: + return SingleChoiceQuestionItem.fromJson(key, json as JsonSurveyQuestionItem); + case SurveyItemType.MultipleChoiceQuestion: + return MultipleChoiceQuestionItem.fromJson(key, json as JsonSurveyQuestionItem); + // TODO: add other question types + default: + throw new Error(`Unsupported item type for initialization: ${json.itemType}`); + } +} + + +// ======================================== +// GROUP ITEM +// ======================================== + +export class GroupItem extends SurveyItem { + itemType: SurveyItemType.Group = SurveyItemType.Group; + items?: Array; + shuffleItems?: boolean; + + constructor(itemFullKey: string) { + super( + itemFullKey, + SurveyItemType.Group + ); + } + + + static fromJson(key: string, json: JsonSurveyItemGroup): GroupItem { + const group = new GroupItem(key); + group.items = json.items; + + group.shuffleItems = json.shuffleItems; + group.metadata = json.metadata; + + group.displayConditions = json.displayConditions ? displayConditionsFromJson(json.displayConditions) : undefined; + return group; + } + + toJson(): JsonSurveyItemGroup { + return { + itemType: SurveyItemType.Group, + items: this.items, + shuffleItems: this.shuffleItems, + metadata: this.metadata, + displayConditions: this.displayConditions ? displayConditionsToJson(this.displayConditions) : undefined, + } + } + + onComponentDeleted(_componentKey: string): void { + // can be ignored for group item + } + + onComponentKeyChanged(_componentKey: string, _newKey: string): void { + // can be ignored for group item + } +} + + + +// ======================================== +// NON QUESTION ITEMS +// ======================================== +export class DisplayItem extends SurveyItem { + itemType: SurveyItemType.Display = SurveyItemType.Display; + components?: Array; + + constructor(itemFullKey: string) { + super(itemFullKey, SurveyItemType.Display); + } + + static fromJson(key: string, json: JsonSurveyDisplayItem): DisplayItem { + const item = new DisplayItem(key); + item.components = json.components?.map(component => DisplayComponent.fromJson(component, undefined, item.key.fullKey)); + item.metadata = json.metadata; + item.displayConditions = json.displayConditions ? displayConditionsFromJson(json.displayConditions) : undefined; + item.templateValues = json.templateValues ? templateValuesFromJson(json.templateValues) : undefined; + return item; + } + + toJson(): JsonSurveyDisplayItem { + return { + itemType: SurveyItemType.Display, + components: this.components?.map(component => component.toJson()) ?? [], + metadata: this.metadata, + displayConditions: this.displayConditions ? displayConditionsToJson(this.displayConditions) : undefined, + templateValues: this.templateValues ? templateValuesToJson(this.templateValues) : undefined, + } + } + + onComponentKeyChanged(oldKey: string, newKey: string): void { + if (this.components) { + for (const component of this.components) { + if (component.key.fullKey === oldKey) { + component.onComponentKeyChanged(newKey); + break; + } + } + } + } + + onComponentDeleted(componentKey: string): void { + this.components = this.components?.filter(c => c.key.fullKey !== componentKey); + } + + onItemKeyChanged(newFullKey: string): void { + this.key = SurveyItemKey.fromFullKey(newFullKey); + if (this.components) { + for (const component of this.components) { + component.onItemKeyChanged(newFullKey); + } + } + } +} + +export class PageBreakItem extends SurveyItem { + itemType: SurveyItemType.PageBreak = SurveyItemType.PageBreak; + + constructor(itemFullKey: string) { + super(itemFullKey, SurveyItemType.PageBreak); + } + + static fromJson(key: string, json: JsonSurveyPageBreakItem): PageBreakItem { + const item = new PageBreakItem(key); + item.metadata = json.metadata; + item.displayConditions = json.displayConditions ? displayConditionsFromJson(json.displayConditions) : undefined; + return item; + } + + toJson(): JsonSurveyPageBreakItem { + return { + itemType: SurveyItemType.PageBreak, + metadata: this.metadata, + displayConditions: this.displayConditions ? displayConditionsToJson(this.displayConditions) : undefined, + } + } + + onComponentKeyChanged(_componentKey: string, _newKey: string): void { + // can be ignored for page break item + } +} + +export class SurveyEndItem extends SurveyItem { + itemType: SurveyItemType.SurveyEnd = SurveyItemType.SurveyEnd; + + constructor(itemFullKey: string) { + super(itemFullKey, SurveyItemType.SurveyEnd); + } + + static fromJson(key: string, json: JsonSurveyEndItem): SurveyEndItem { + const item = new SurveyEndItem(key); + item.metadata = json.metadata; + item.displayConditions = json.displayConditions ? displayConditionsFromJson(json.displayConditions) : undefined; + item.templateValues = json.templateValues ? templateValuesFromJson(json.templateValues) : undefined; + return item; + } + + toJson(): JsonSurveyEndItem { + return { + itemType: SurveyItemType.SurveyEnd, + metadata: this.metadata, + displayConditions: this.displayConditions ? displayConditionsToJson(this.displayConditions) : undefined, + templateValues: this.templateValues ? templateValuesToJson(this.templateValues) : undefined, + } + } + + onComponentKeyChanged(_componentKey: string, _newKey: string): void { + // can be ignored for survey end item + } +} + + +// ======================================== +// QUESTION ITEMS +// ======================================== +export abstract class QuestionItem extends SurveyItem { + header?: { + title?: TextComponent; + subtitle?: TextComponent; + helpPopover?: TextComponent; + } + body?: { + topContent?: Array; + bottomContent?: Array; + } + footer?: TextComponent; + confidentiality?: { + mode: ConfidentialMode; + mapToKey?: string; + } + + abstract responseConfig: ItemComponent; + + _readGenericAttributes(json: JsonSurveyQuestionItem) { + this.metadata = json.metadata; + this.displayConditions = json.displayConditions ? displayConditionsFromJson(json.displayConditions) : undefined; + this.disabledConditions = json.disabledConditions ? disabledConditionsFromJson(json.disabledConditions) : undefined; + this.templateValues = json.templateValues ? templateValuesFromJson(json.templateValues) : undefined; + this.validations = json.validations ? Object.fromEntries(Object.entries(json.validations).map(([key, value]) => [key, Expression.fromJson(value)])) : undefined; + + if (json.header) { + this.header = { + title: json.header?.title ? DisplayComponent.fromJson(json.header?.title, undefined, this.key.fullKey) as TextComponent : undefined, + subtitle: json.header?.subtitle ? DisplayComponent.fromJson(json.header?.subtitle, undefined, this.key.fullKey) as TextComponent : undefined, + helpPopover: json.header?.helpPopover ? DisplayComponent.fromJson(json.header?.helpPopover, undefined, this.key.fullKey) as TextComponent : undefined, + } + } + + if (json.body) { + this.body = { + topContent: json.body?.topContent?.map(component => DisplayComponent.fromJson(component, undefined, this.key.fullKey)), + bottomContent: json.body?.bottomContent?.map(component => DisplayComponent.fromJson(component, undefined, this.key.fullKey)), + } + } + + this.footer = json.footer ? TextComponent.fromJson(json.footer, undefined, this.key.fullKey) as TextComponent : undefined; + this.confidentiality = json.confidentiality; + } + + toJson(): JsonSurveyQuestionItem { + const json: JsonSurveyQuestionItem = { + itemType: this.itemType, + responseConfig: this.responseConfig.toJson(), + metadata: this.metadata, + displayConditions: this.displayConditions ? displayConditionsToJson(this.displayConditions) : undefined, + disabledConditions: this.disabledConditions ? disabledConditionsToJson(this.disabledConditions) : undefined, + templateValues: this.templateValues ? templateValuesToJson(this.templateValues) : undefined, + validations: this.validations ? Object.fromEntries(Object.entries(this.validations).map(([key, value]) => [key, value?.toJson()])) : undefined, + } + + if (this.header) { + json.header = { + title: this.header?.title?.toJson(), + subtitle: this.header?.subtitle?.toJson(), + helpPopover: this.header?.helpPopover?.toJson(), + } + } + + if (this.body) { + json.body = { + topContent: this.body?.topContent?.map(component => component.toJson()), + bottomContent: this.body?.bottomContent?.map(component => component.toJson()), + } + } + + json.footer = this.footer?.toJson(); + json.confidentiality = this.confidentiality; + + return json; + } + + onComponentKeyChanged(oldKey: string, newKey: string): void { + if (this.disabledConditions?.components?.[oldKey]) { + this.disabledConditions.components[newKey] = this.disabledConditions.components[oldKey]; + delete this.disabledConditions.components[oldKey]; + } + + if (this.displayConditions?.components?.[oldKey]) { + this.displayConditions.components[newKey] = this.displayConditions.components[oldKey]; + delete this.displayConditions.components[oldKey]; + } + + if (this.header?.title?.key.fullKey === oldKey) { + this.header.title.onComponentKeyChanged(newKey); + return; + } + if (this.header?.subtitle?.key.fullKey === oldKey) { + this.header.subtitle.onComponentKeyChanged(newKey); + return; + } + if (this.header?.helpPopover?.key.fullKey === oldKey) { + this.header.helpPopover.onComponentKeyChanged(newKey); + return; + } + + for (const component of this.body?.topContent || []) { + if (component.key.fullKey === oldKey) { + component.onComponentKeyChanged(newKey); + break; + } + } + for (const component of this.body?.bottomContent || []) { + if (component.key.fullKey === oldKey) { + component.onComponentKeyChanged(newKey); + break; + } + } + + if (this.footer?.key.fullKey === oldKey) { + this.footer.onComponentKeyChanged(newKey); + return; + } + + if (this.responseConfig.key.fullKey === oldKey) { + this.responseConfig.onComponentKeyChanged(newKey); + return; + } + } + + + onComponentDeleted(componentKey: string): void { + if (this.header?.title?.key.fullKey === componentKey) { + this.header.title = undefined; + } + if (this.header?.subtitle?.key.fullKey === componentKey) { + this.header.subtitle = undefined; + } + if (this.header?.helpPopover?.key.fullKey === componentKey) { + this.header.helpPopover = undefined; + } + if (this.body?.topContent?.some(c => c.key.fullKey === componentKey)) { + this.body.topContent = this.body.topContent?.filter(c => c.key.fullKey !== componentKey); + } + if (this.body?.bottomContent?.some(c => c.key.fullKey === componentKey)) { + this.body.bottomContent = this.body.bottomContent?.filter(c => c.key.fullKey !== componentKey); + } + if (this.footer?.key.fullKey === componentKey) { + this.footer = undefined; + } + + if (componentKey.startsWith(this.responseConfig.key.fullKey)) { + this.responseConfig.onSubComponentDeleted?.(componentKey); + } + + if (this.displayConditions?.components?.[componentKey]) { + delete this.displayConditions.components[componentKey]; + } + + if (this.disabledConditions?.components?.[componentKey]) { + delete this.disabledConditions.components[componentKey]; + } + } + + onItemKeyChanged(newFullKey: string): void { + super.onItemKeyChanged(newFullKey); + this.responseConfig.onItemKeyChanged(newFullKey); + if (this.header?.title) { + this.header.title.onItemKeyChanged(newFullKey); + } + if (this.header?.subtitle) { + this.header.subtitle.onItemKeyChanged(newFullKey); + } + if (this.header?.helpPopover) { + this.header.helpPopover.onItemKeyChanged(newFullKey); + } + if (this.body?.topContent) { + for (const component of this.body.topContent) { + component.onItemKeyChanged(newFullKey); + } + } + if (this.body?.bottomContent) { + for (const component of this.body.bottomContent) { + component.onItemKeyChanged(newFullKey); + } + } + if (this.footer) { + this.footer.onItemKeyChanged(newFullKey); + } + } +} + +abstract class ScgMcgQuestionItem extends QuestionItem { + responseConfig!: ScgMcgChoiceResponseConfig; + + constructor(itemFullKey: string, itemType: SurveyItemType.SingleChoiceQuestion | SurveyItemType.MultipleChoiceQuestion) { + super(itemFullKey, itemType); + this.responseConfig = new ScgMcgChoiceResponseConfig(itemType === SurveyItemType.SingleChoiceQuestion ? 'scg' : 'mcg', undefined, this.key.fullKey); + } + + onComponentKeyChanged(oldKey: string, newKey: string): void { + super.onComponentKeyChanged(oldKey, newKey); + + if (oldKey.startsWith(this.responseConfig.key.fullKey)) { + for (const comp of this.responseConfig.items || []) { + if (comp.key.fullKey === oldKey) { + comp.onComponentKeyChanged(newKey); + break; + } + } + } + } +} + +export class SingleChoiceQuestionItem extends ScgMcgQuestionItem { + itemType: SurveyItemType.SingleChoiceQuestion = SurveyItemType.SingleChoiceQuestion; + responseConfig!: ScgMcgChoiceResponseConfig; + + constructor(itemFullKey: string) { + super(itemFullKey, SurveyItemType.SingleChoiceQuestion); + } + + static fromJson(key: string, json: JsonSurveyQuestionItem): SingleChoiceQuestionItem { + const item = new SingleChoiceQuestionItem(key); + item.responseConfig = ScgMcgChoiceResponseConfig.fromJson(json.responseConfig, undefined, item.key.fullKey); + item._readGenericAttributes(json); + return item; + } +} + +export class MultipleChoiceQuestionItem extends ScgMcgQuestionItem { + itemType: SurveyItemType.MultipleChoiceQuestion = SurveyItemType.MultipleChoiceQuestion; + responseConfig!: ScgMcgChoiceResponseConfig; + + constructor(itemFullKey: string) { + super(itemFullKey, SurveyItemType.MultipleChoiceQuestion); + } + + static fromJson(key: string, json: JsonSurveyQuestionItem): MultipleChoiceQuestionItem { + const item = new MultipleChoiceQuestionItem(key); + item.responseConfig = ScgMcgChoiceResponseConfig.fromJson(json.responseConfig, undefined, item.key.fullKey); + item._readGenericAttributes(json); + return item; + } +} diff --git a/src/survey/items/types.ts b/src/survey/items/types.ts new file mode 100644 index 0000000..ede8fc3 --- /dev/null +++ b/src/survey/items/types.ts @@ -0,0 +1,17 @@ +export enum SurveyItemType { + Group = 'group', + Display = 'display', + PageBreak = 'pageBreak', + SurveyEnd = 'surveyEnd', + + SingleChoiceQuestion = 'singleChoiceQuestion', + MultipleChoiceQuestion = 'multipleChoiceQuestion', +} + + + +export enum ConfidentialMode { + Add = 'add', + Replace = 'replace' +} + diff --git a/src/survey/items/utils.ts b/src/survey/items/utils.ts new file mode 100644 index 0000000..d717908 --- /dev/null +++ b/src/survey/items/utils.ts @@ -0,0 +1,53 @@ +import { Expression, JsonExpression } from "../../expressions"; + +export interface DisplayConditions { + root?: Expression; + components?: { + [componentKey: string]: Expression | undefined; + } +} + +export interface JsonDisplayConditions { + root?: JsonExpression; + components?: { + [componentKey: string]: JsonExpression | undefined; + } +} + +export interface JsonDisabledConditions { + components?: { + [componentKey: string]: JsonExpression | undefined; + } +} + +export interface DisabledConditions { + components?: { + [componentKey: string]: Expression | undefined; + } +} + +export const displayConditionsFromJson = (json: JsonDisplayConditions): DisplayConditions => { + return { + root: json.root ? Expression.fromJson(json.root) : undefined, + components: json.components ? Object.fromEntries(Object.entries(json.components).map(([key, value]) => [key, Expression.fromJson(value)])) : undefined + } +} + +export const displayConditionsToJson = (displayConditions: DisplayConditions): JsonDisplayConditions => { + return { + root: displayConditions.root ? displayConditions.root.toJson() : undefined, + components: displayConditions.components ? Object.fromEntries(Object.entries(displayConditions.components).map(([key, value]) => [key, value?.toJson()])) : undefined + } +} + +export const disabledConditionsFromJson = (json: JsonDisabledConditions): DisabledConditions => { + return { + components: json.components ? Object.fromEntries(Object.entries(json.components).map(([key, value]) => [key, Expression.fromJson(value)])) : undefined + } +} + +export const disabledConditionsToJson = (disabledConditions: DisabledConditions): JsonDisabledConditions => { + return { + components: disabledConditions.components ? Object.fromEntries(Object.entries(disabledConditions.components).map(([key, value]) => [key, value?.toJson()])) : undefined + } +} diff --git a/src/survey/responses/index.ts b/src/survey/responses/index.ts new file mode 100644 index 0000000..37b991c --- /dev/null +++ b/src/survey/responses/index.ts @@ -0,0 +1,3 @@ +export * from './item-response'; +export * from './response-meta'; +export * from './survey-response'; \ No newline at end of file diff --git a/src/survey/responses/item-response.ts b/src/survey/responses/item-response.ts new file mode 100644 index 0000000..f3b0b17 --- /dev/null +++ b/src/survey/responses/item-response.ts @@ -0,0 +1,125 @@ +import { SurveyItemKey } from "../item-component-key"; +import { ConfidentialMode, SurveyItemType } from "../items"; +import { ValueType } from "../utils/types"; +import { JsonResponseMeta, ResponseMeta } from "./response-meta"; + + +export interface JsonSurveyItemResponse { + key: string; + itemType: SurveyItemType; + meta?: JsonResponseMeta; + response?: JsonResponseItem; + confidentialMode?: ConfidentialMode; + mapToKey?: string; +} + +export interface JsonResponseItem { + value?: ValueType; + slotValues?: { + [key: string]: ValueType; + }; +} + + +/** + * SurveyItemResponse to store the response of a survey item. + */ +export class SurveyItemResponse { + key: SurveyItemKey; + itemType: SurveyItemType; + meta?: ResponseMeta; + response?: ResponseItem; + confidentiality?: { + mode: ConfidentialMode; + mapToKey?: string; + }; + + constructor(itemDef: { + key: SurveyItemKey; + itemType: SurveyItemType; + }, response?: ResponseItem) { + this.key = itemDef.key; + this.itemType = itemDef.itemType; + this.response = response; + } + + + + toJson(): JsonSurveyItemResponse { + return { + key: this.key.fullKey, + itemType: this.itemType, + meta: this.meta?.toJson(), + response: this.response?.toJson(), + confidentialMode: this.confidentiality?.mode, + mapToKey: this.confidentiality?.mapToKey, + }; + } + + static fromJson(json: JsonSurveyItemResponse): SurveyItemResponse { + const itemDef: { + key: SurveyItemKey; + itemType: SurveyItemType; + } = { + key: SurveyItemKey.fromFullKey(json.key), + itemType: json.itemType, + }; + + const response = json.response ? ResponseItem.fromJson(json.response) : undefined; + + const newResponse = new SurveyItemResponse(itemDef, response); + newResponse.meta = json.meta ? ResponseMeta.fromJson(json.meta) : undefined; + newResponse.confidentiality = json.confidentialMode ? { + mode: json.confidentialMode, + mapToKey: json.mapToKey, + } : undefined; + + return newResponse; + } +} + +export class ResponseItem { + private _value?: ValueType; + private _slotValues?: { + [key: string]: ValueType; + }; + + constructor(value?: ValueType, slotValues?: { + [key: string]: ValueType; + }) { + this._value = value; + this._slotValues = slotValues; + } + get(slotKey?: string): ValueType | undefined { + if (slotKey) { + return this._slotValues?.[slotKey]; + } + return this._value; + } + + setValue(value: ValueType) { + this._value = value; + } + + setSlotValue(slotKey: string, value: ValueType) { + if (this._slotValues === undefined) { + this._slotValues = {}; + } + this._slotValues[slotKey] = value; + } + + toJson(): JsonResponseItem | undefined { + return { + value: this._value, + slotValues: this._slotValues, + }; + } + + clone(): ResponseItem { + return new ResponseItem(this._value, this._slotValues); + } + + static fromJson(json: JsonResponseItem): ResponseItem { + return new ResponseItem(json.value, json.slotValues); + } +} diff --git a/src/survey/responses/response-meta.ts b/src/survey/responses/response-meta.ts new file mode 100644 index 0000000..e33d555 --- /dev/null +++ b/src/survey/responses/response-meta.ts @@ -0,0 +1,60 @@ +export type TimestampType = 'displayed' | 'responded'; + +export interface JsonResponseMeta { + position: number; // position in the list + localeCode?: string; + // timestamps: + displayed: Array; + responded: Array; +} + +const TIMESTAMP_LIMIT = 100; + +export class ResponseMeta { + private _position: number; + private _displayed: Array; + private _responded: Array; + + constructor() { + this._position = -1; + this._displayed = []; + this._responded = []; + } + + toJson(): JsonResponseMeta { + return { + position: this._position, + displayed: this._displayed, + responded: this._responded, + }; + } + + static fromJson(json: JsonResponseMeta): ResponseMeta { + const meta = new ResponseMeta(); + meta._position = json.position; + meta._displayed = json.displayed; + meta._responded = json.responded; + return meta; + } + + setPosition(position: number) { + this._position = position; + } + + addTimestamp(type: TimestampType, timestamp: number) { + switch (type) { + case 'displayed': + this._displayed.push(timestamp); + if (this._displayed.length > TIMESTAMP_LIMIT) { + this._displayed.splice(0, 1); + } + break; + case 'responded': + this._responded.push(timestamp); + if (this._responded.length > TIMESTAMP_LIMIT) { + this._responded.splice(0, 1); + } + break; + } + } +} \ No newline at end of file diff --git a/src/survey/responses/survey-response.ts b/src/survey/responses/survey-response.ts new file mode 100644 index 0000000..3bd7390 --- /dev/null +++ b/src/survey/responses/survey-response.ts @@ -0,0 +1,53 @@ +import { JsonSurveyItemResponse, SurveyItemResponse } from "./item-response"; + +/** + * SurveyResponse to store the responses of a survey. + */ +export class SurveyResponse { + key: string; + participantId?: string; + submittedAt?: number; + openedAt?: number; + versionId: string; + responses: { + [key: string]: SurveyItemResponse; + }; + context?: { + [key: string]: string; + }; + + constructor(key: string, versionId: string) { + this.key = key; + this.participantId = ''; + this.submittedAt = 0; + this.versionId = versionId; + this.responses = {}; + } + + toJson(): JsonSurveyResponse { + return { + key: this.key, + participantId: this.participantId, + submittedAt: this.submittedAt, + openedAt: this.openedAt, + versionId: this.versionId, + responses: Object.values(this.responses).map(r => r.toJson()), + context: this.context, + }; + } +} + +/** + * JsonSurveyResponse is the JSON representation of a survey response. + */ +export interface JsonSurveyResponse { + key: string; + participantId?: string; + submittedAt?: number; + openedAt?: number; + versionId: string; + responses: JsonSurveyItemResponse[]; + context?: { + [key: string]: string; + }; // key value pairs of data +} diff --git a/src/survey/survey-file-schema.ts b/src/survey/survey-file-schema.ts new file mode 100644 index 0000000..d3b729f --- /dev/null +++ b/src/survey/survey-file-schema.ts @@ -0,0 +1,56 @@ +import { Expression } from "../data_types/expression"; +import { JsonSurveyItem } from "./items"; +import { JsonSurveyTranslations } from "./utils/translations"; + +export const CURRENT_SURVEY_SCHEMA = 'https://github.com/case-framework/survey-engine/schemas/survey-schema.json'; +// TODO: generate schema from survey-engine.ts + +export interface SurveyVersion { + id?: string; + surveyKey: string; + published?: number; + unpublished?: number; + versionId?: string; + survey: JsonSurvey; +} + +type ItemKey = string; + +export type JsonSurvey = { + $schema: string; + prefillRules?: Expression[]; + maxItemsPerPage?: { large: number, small: number }; + availableFor?: string; + requireLoginBeforeSubmission?: boolean; + + surveyItems: { + [itemKey: ItemKey]: JsonSurveyItem; + } + + metadata?: { + [key: string]: string + } + + translations?: JsonSurveyTranslations; +} + + + + +// TODO: move to survey-item-component.ts +export interface JsonItemComponent { + key: string; // unique identifier + type: string; // type of the component + styles?: { + classNames?: string | { + [key: string]: string; + } + } + properties?: { + [key: string]: string | number | boolean | { + type: 'templateValue', + templateValueKey: string; + } + } + items?: Array; +} diff --git a/src/survey/survey.ts b/src/survey/survey.ts new file mode 100644 index 0000000..4d4cc03 --- /dev/null +++ b/src/survey/survey.ts @@ -0,0 +1,190 @@ +import { Expression } from "../data_types/expression"; +import { CURRENT_SURVEY_SCHEMA, JsonSurvey, } from "./survey-file-schema"; +import { SurveyItemTranslations, SurveyTranslations } from "./utils/translations"; +import { GroupItem, QuestionItem, SurveyItem } from "./items"; +import { ExpectedValueType } from "./utils/types"; +import { ResponseConfigComponent, ValueRefTypeLookup } from "./components"; +import { ReferenceUsage } from "./utils/value-reference"; + + +abstract class SurveyBase { + prefillRules?: Expression[]; + maxItemsPerPage?: { large: number, small: number }; + availableFor?: string; + requireLoginBeforeSubmission?: boolean; + + metadata?: { + [key: string]: string + } +} + + +export class Survey extends SurveyBase { + surveyItems: { + [itemKey: string]: SurveyItem; + } = {}; + + private _translations?: SurveyTranslations; + + constructor(key: string = 'survey') { + super(); + this.surveyItems = { + [key]: new GroupItem(key), + }; + this._translations = new SurveyTranslations(); + } + + static fromJson(json: object): Survey { + const survey = new Survey(); + const rawSurvey = json as JsonSurvey; + if (!rawSurvey.surveyItems || Object.keys(rawSurvey.surveyItems).length === 0) { + throw new Error('surveyItems is required'); + } + if (rawSurvey.$schema !== CURRENT_SURVEY_SCHEMA) { + throw new Error(`Unsupported survey schema: ${rawSurvey.$schema}`); + } + + survey.surveyItems = {} + Object.keys(rawSurvey.surveyItems).forEach(itemFullKey => { + survey.surveyItems[itemFullKey] = SurveyItem.fromJson(itemFullKey, rawSurvey.surveyItems[itemFullKey]); + }); + + // Parse other fields + survey._translations = new SurveyTranslations(rawSurvey.translations); + + if (rawSurvey.prefillRules) { + survey.prefillRules = rawSurvey.prefillRules; + } + if (rawSurvey.maxItemsPerPage) { + survey.maxItemsPerPage = rawSurvey.maxItemsPerPage; + } + if (rawSurvey.availableFor) { + survey.availableFor = rawSurvey.availableFor; + } + if (rawSurvey.requireLoginBeforeSubmission !== undefined) { + survey.requireLoginBeforeSubmission = rawSurvey.requireLoginBeforeSubmission; + } + if (rawSurvey.metadata) { + survey.metadata = rawSurvey.metadata; + } + + return survey; + } + + toJson(): JsonSurvey { + const json: JsonSurvey = { + $schema: CURRENT_SURVEY_SCHEMA, + surveyItems: Object.fromEntries(Object.entries(this.surveyItems).map(([itemFullKey, item]) => [itemFullKey, item.toJson()])), + }; + + // Export other fields + json.translations = this._translations?.toJson(); + + if (this.prefillRules) { + json.prefillRules = this.prefillRules; + } + if (this.maxItemsPerPage) { + json.maxItemsPerPage = this.maxItemsPerPage; + } + if (this.availableFor) { + json.availableFor = this.availableFor; + } + if (this.requireLoginBeforeSubmission !== undefined) { + json.requireLoginBeforeSubmission = this.requireLoginBeforeSubmission; + } + if (this.metadata) { + json.metadata = this.metadata; + } + + return json; + } + + get locales(): string[] { + return this._translations?.locales || []; + } + + get surveyKey(): string { + let key: string | undefined; + for (const item of Object.values(this.surveyItems)) { + if (item.key.isRoot) { + key = item.key.fullKey; + break; + } + } + if (!key) { + throw new Error('Survey key not found'); + } + return key; + } + + get rootItem(): GroupItem { + return this.surveyItems[this.surveyKey] as GroupItem; + } + + get translations(): SurveyTranslations { + if (!this._translations) { + this._translations = new SurveyTranslations(); + } + return this._translations; + } + + getItemTranslations(fullItemKey: string): SurveyItemTranslations | undefined { + const item = this.surveyItems[fullItemKey]; + if (!item) { + throw new Error(`Item ${fullItemKey} not found`); + } + + return this._translations?.getItemTranslations(fullItemKey); + } + + getResponseValueReferences(byType?: ExpectedValueType): ValueRefTypeLookup { + let valueRefs: ValueRefTypeLookup = {}; + for (const item of Object.values(this.surveyItems)) { + if (item instanceof QuestionItem) { + const responseConfig = item.responseConfig as ResponseConfigComponent; + if (responseConfig) { + const responseValueRefs = responseConfig.valueReferences; + if (byType) { + Object.keys(responseValueRefs).forEach(key => { + if (responseValueRefs[key] === byType) { + valueRefs[key] = responseValueRefs[key]; + } + }); + } else { + valueRefs = { ...valueRefs, ...responseValueRefs }; + } + } + } + } + return valueRefs; + } + + /** + * Get all reference usages for the survey + * @param forItemKey - optional item key to filter usages for a specific item and its children (if not provided, all usages are returned) + * @returns all reference usages for the survey (or for a specific item and its children) + */ + getReferenceUsages(forItemKey?: string): ReferenceUsage[] { + const usages: ReferenceUsage[] = []; + for (const item of Object.values(this.surveyItems)) { + if (forItemKey && item.key.fullKey !== forItemKey && !item.key.fullKey.startsWith(forItemKey + '.')) { + continue; + } + usages.push(...item.getReferenceUsages()); + } + return usages; + } + + findInvalidReferenceUsages(): ReferenceUsage[] { + const usages = this.getReferenceUsages(); + const valueRefs = this.getResponseValueReferences(); + + const invalidUsages: ReferenceUsage[] = []; + for (const usage of usages) { + if (!valueRefs[usage.valueReference.toString()]) { + invalidUsages.push(usage); + } + } + return invalidUsages; + } +} \ No newline at end of file diff --git a/src/survey/utils/content.ts b/src/survey/utils/content.ts new file mode 100644 index 0000000..52b6cd7 --- /dev/null +++ b/src/survey/utils/content.ts @@ -0,0 +1,43 @@ +export enum ContentType { + CQM = 'CQM', + md = 'md' +} + +export enum AttributionType { + style = 'style', + template = 'template' +} + +export type StyleAttribution = { + type: AttributionType.style; + styleKey: string; + start: number; + end: number; +} + +export type TemplateAttribution = { + type: AttributionType.template; + templateKey: string; + position: number; +} + + +export type Attribution = StyleAttribution | TemplateAttribution; + + +// TODO: create JSON schema +// TODO: create classes to represent the content + +export type CQMContent = { + type: ContentType.CQM; + content: string; + attributions?: Array; +} + +export type MDContent = { + type: ContentType.md; + content: string; +} + +export type Content = CQMContent | MDContent; + diff --git a/src/survey/utils/context.ts b/src/survey/utils/context.ts new file mode 100644 index 0000000..a7659e6 --- /dev/null +++ b/src/survey/utils/context.ts @@ -0,0 +1,9 @@ +import { ValueType } from "./types"; +import { Expression } from "../../expressions"; + +export interface SurveyContext { + participantFlags?: { [key: string]: string }; + locale: string; + customValues?: { [key: string]: ValueType }; + customExpressions?: { [key: string]: (args?: Array) => ValueType }; +} diff --git a/src/survey/utils/index.ts b/src/survey/utils/index.ts new file mode 100644 index 0000000..d9a95aa --- /dev/null +++ b/src/survey/utils/index.ts @@ -0,0 +1,4 @@ +export * from './content'; +export * from './translations'; +export * from './types'; +export * from './value-reference'; \ No newline at end of file diff --git a/src/survey/utils/translations.ts b/src/survey/utils/translations.ts new file mode 100644 index 0000000..2c4e673 --- /dev/null +++ b/src/survey/utils/translations.ts @@ -0,0 +1,265 @@ +import { structuredCloneMethod } from "../../utils"; +import { Content } from "./content"; + + +export const validateLocale = (locale: string): void => { + if (locale.trim() === '') { + throw new Error('Locale cannot be empty'); + } +} + +export class SurveyItemTranslations { + private _translations?: { + [locale: string]: JsonComponentContent; + }; + + constructor() { + this._translations = {}; + } + + setContent(locale: string, contentKey: string, content?: Content): void { + validateLocale(locale); + if (!this._translations?.[locale]) { + if (!content) { + // No need to do anything if content is undefined + return + } + this._translations![locale] = {}; + } + if (!content) { + delete this._translations![locale][contentKey]; + } else { + this._translations![locale][contentKey] = content; + } + } + + setAllForLocale(locale: string, content?: JsonComponentContent): void { + validateLocale(locale); + if (!this._translations?.[locale]) { + if (!content) { + // No need to do anything if content is undefined + return + } + this._translations![locale] = {}; + } + this._translations![locale] = content || {}; + } + + get locales(): string[] { + return Object.keys(this._translations || {}); + } + + getAllForLocale(locale: string): JsonComponentContent | undefined { + return this._translations?.[locale]; + } + + getContent(locale: string, contentKey: string, fallbackLocale?: string): Content | undefined { + const content = this._translations?.[locale]?.[contentKey]; + if (content) { + return content; + } + if (fallbackLocale) { + return this._translations?.[fallbackLocale]?.[contentKey]; + } + return undefined; + } +} + +export interface SurveyCardTranslations { + [locale: string]: JsonSurveyCardContent; +} + + +export class SurveyTranslations { + private _translations: JsonSurveyTranslations; + + constructor(translations?: JsonSurveyTranslations) { + this._translations = translations || {}; + } + + + toJson(): JsonSurveyTranslations | undefined { + if (this.locales.length === 0) { + return undefined; + } + return this._translations; + } + + get locales(): string[] { + return Object.keys(this._translations); + } + + removeLocale(locale: string): void { + validateLocale(locale); + delete this._translations[locale]; + } + + renameLocale(oldLocale: string, newLocale: string): void { + validateLocale(oldLocale); + validateLocale(newLocale); + if (this._translations[oldLocale]) { + this._translations[newLocale] = this._translations[oldLocale]; + delete this._translations[oldLocale]; + } + } + + cloneLocaleAs(locale: string, newLocale: string): void { + validateLocale(locale); + validateLocale(newLocale); + if (this._translations[locale]) { + this._translations[newLocale] = structuredCloneMethod(this._translations[locale]); + } + } + + get surveyCardContent(): SurveyCardTranslations | undefined { + const translations: SurveyCardTranslations = {}; + for (const locale of this.locales) { + const contentForLocale = this._translations?.[locale]?.surveyCardContent; + if (contentForLocale) { + translations[locale] = contentForLocale as JsonSurveyCardContent; + } + } + return translations; + } + + setSurveyCardContent(locale: string, content?: JsonSurveyCardContent): void { + validateLocale(locale); + if (!this._translations[locale]) { + if (!content) { + // No need to do anything if content is undefined + return + } + this._translations[locale] = {}; + } + this._translations[locale].surveyCardContent = content; + } + + getItemTranslations(fullItemKey: string): SurveyItemTranslations | undefined { + const itemTranslations: SurveyItemTranslations = new SurveyItemTranslations(); + for (const locale of this.locales) { + const contentForLocale = this._translations?.[locale]?.[fullItemKey]; + itemTranslations.setAllForLocale(locale, contentForLocale as JsonComponentContent); + } + return itemTranslations; + } + + setItemTranslations(fullItemKey: string, itemContent?: SurveyItemTranslations): void { + itemContent?.locales.forEach(locale => validateLocale(locale)); + if (!itemContent) { + for (const locale of this.locales) { + if (this._translations[locale]?.[fullItemKey]) { + delete this._translations[locale][fullItemKey]; + } + } + } else { + const localesInUpdate = itemContent.locales; + // Add new locales to the translations + for (const locale of localesInUpdate) { + if (!this.locales.includes(locale)) { + this._translations[locale] = {}; + } + } + for (const locale of this.locales) { + if (localesInUpdate.includes(locale)) { + if (!this._translations[locale]) { + this._translations[locale] = {}; + } + this._translations[locale][fullItemKey] = itemContent.getAllForLocale(locale) ?? {}; + } else { + delete this._translations[locale][fullItemKey]; + } + } + } + } + + /** + * Rename a component key (within an item) - update key in all translations and remove old key + * @param itemKey - The key of the item + * @param oldKey - The old key of the component + * @param newKey - The new key of the component + */ + onComponentKeyChanged(itemKey: string, oldKey: string, newKey: string): void { + for (const locale of this.locales) { + const itemTranslations = this._translations?.[locale]?.[itemKey] as JsonComponentContent; + if (itemTranslations) { + for (const key of Object.keys(itemTranslations)) { + if (key.startsWith(oldKey + '.') || key === oldKey) { + itemTranslations[key.replace(oldKey, newKey)] = { ...itemTranslations[key] }; + delete itemTranslations[key]; + } + } + } + } + } + + /** + * Remove all translations for a component + * @param fullItemKey - The full key of the item + * @param componentKey - The key of the component + */ + onComponentDeleted(fullItemKey: string, componentKey: string): void { + for (const locale of this.locales) { + const itemTranslations = this._translations?.[locale]?.[fullItemKey]; + if (itemTranslations) { + for (const key of Object.keys(itemTranslations)) { + if (key.startsWith(componentKey + '.') || key === componentKey) { + delete itemTranslations[key as keyof typeof itemTranslations]; + } + } + } + } + } + + /** + * Rename an item key - update key in all translations and remove old key + * @param oldKey - The old key + * @param newKey - The new key + */ + onItemKeyChanged(oldKey: string, newKey: string): void { + for (const locale of this.locales) { + const itemTranslations = this._translations?.[locale]?.[oldKey]; + if (itemTranslations) { + this._translations![locale][newKey] = { ...itemTranslations }; + delete this._translations![locale][oldKey]; + } + } + } + + /** + * Remove all translations for an item and all its children + * @param fullItemKey - The full key of the item + */ + onItemDeleted(fullItemKey: string): void { + for (const locale of this.locales) { + for (const key of Object.keys(this._translations?.[locale] || {})) { + if (key.startsWith(fullItemKey + '.') || key === fullItemKey) { + delete this._translations![locale][key]; + } + } + } + } +} + + +/** + * Json Schemas for translations + */ +export type JsonComponentContent = { + [contentKey: string]: Content; +} + + +export interface JsonSurveyCardContent { + name?: Content; + description?: Content; + typicalDuration?: Content; +} + + +export interface JsonSurveyTranslations { + [locale: string]: { + [itemKey: string]: JsonSurveyCardContent | JsonComponentContent; + } & { + surveyCardContent?: JsonSurveyCardContent; + } +} diff --git a/src/survey/utils/types.ts b/src/survey/utils/types.ts new file mode 100644 index 0000000..40ac1ca --- /dev/null +++ b/src/survey/utils/types.ts @@ -0,0 +1,32 @@ +export type ValueType = string | number | boolean | Date | string[] | number[] | Date[]; + +export enum ExpectedValueType { + String = 'string', + Number = 'number', + Boolean = 'boolean', + Date = 'date', + StringArray = 'string[]', + NumberArray = 'number[]', + DateArray = 'date[]', +} + +export const initValueForType = (returnType: ExpectedValueType): ValueType => { + switch (returnType) { + case ExpectedValueType.String: + return ''; + case ExpectedValueType.Number: + return 0; + case ExpectedValueType.Boolean: + return false; + case ExpectedValueType.Date: + return new Date(); + case ExpectedValueType.StringArray: + return []; + case ExpectedValueType.NumberArray: + return []; + case ExpectedValueType.DateArray: + return []; + default: + throw new Error('Invalid return type: ' + returnType); + } +} \ No newline at end of file diff --git a/src/survey/utils/value-reference.ts b/src/survey/utils/value-reference.ts new file mode 100644 index 0000000..9059c8a --- /dev/null +++ b/src/survey/utils/value-reference.ts @@ -0,0 +1,70 @@ +import { ItemComponentKey, SurveyItemKey } from "../item-component-key"; + +const SEPARATOR = '...'; + +export enum ValueReferenceMethod { + get = 'get', + isDefined = 'isDefined', +} + + + +export class ValueReference { + _itemKey: SurveyItemKey; + _name: ValueReferenceMethod; + _slotKey?: ItemComponentKey; + + constructor(str: string) { + const parts = str.split(SEPARATOR); + if (parts.length < 2) { + throw new Error('Invalid value reference: ' + str); + } + this._itemKey = SurveyItemKey.fromFullKey(parts[0]); + if (!Object.values(ValueReferenceMethod).includes(parts[1] as ValueReferenceMethod)) { + throw new Error(`Invalid value reference method: ${parts[1]}`); + } + this._name = parts[1] as ValueReferenceMethod; + if (parts.length > 2) { + this._slotKey = ItemComponentKey.fromFullKey(parts[2], this._itemKey.fullKey); + } + } + + get itemKey(): SurveyItemKey { + return this._itemKey; + } + + get name(): ValueReferenceMethod { + return this._name; + } + + get slotKey(): ItemComponentKey | undefined { + return this._slotKey; + } + + set itemKey(itemKey: SurveyItemKey) { + this._itemKey = itemKey; + } + + toString(): string { + return `${this._itemKey.fullKey}${SEPARATOR}${this._name}${this._slotKey ? SEPARATOR + this._slotKey.fullKey : ''}`; + } + + static fromParts(itemKey: SurveyItemKey, name: ValueReferenceMethod, slotKey?: ItemComponentKey): ValueReference { + return new ValueReference(`${itemKey.fullKey}${SEPARATOR}${name}${slotKey ? SEPARATOR + slotKey.fullKey : ''}`); + } +} + + +export enum ReferenceUsageType { + displayConditions = 'displayConditions', + templateValues = 'templateValues', + validations = 'validations', + disabledConditions = 'disabledConditions', +} + +export interface ReferenceUsage { + fullItemKey: string; + fullComponentKey?: string; + usageType?: ReferenceUsageType; + valueReference: ValueReference; +} diff --git a/src/utils.ts b/src/utils.ts index a411a5c..fcc6e02 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,12 +1,7 @@ -import { SurveyItem, isSurveyGroupItem, LocalizedString, SurveyGroupItem, SurveySingleItem, SurveySingleItemResponse } from "./data_types"; - -export const pickRandomListItem = (items: Array): any => { +/* TODO: export const pickRandomListItem = (items: Array): any => { return items[Math.floor(Math.random() * items.length)]; } -export const removeItemByKey = (items: Array, key: string): Array => { - return items.filter(item => item.key !== key); -} export const printResponses = (responses: SurveySingleItemResponse[], prefix: string) => { @@ -14,36 +9,28 @@ export const printResponses = (responses: SurveySingleItemResponse[], prefix: st console.log(prefix, item); })); } - -export const printSurveyItem = (surveyItem: SurveyItem, prefix: string) => { - console.log(prefix + surveyItem.key); - if (isSurveyGroupItem(surveyItem)) { - surveyItem.items.forEach(i => { - printSurveyItem(i, prefix + '\t'); - }) - } else { - if (!surveyItem.components) { return; } - console.log(surveyItem.components.items.map(c => { - const content = c.content ? c.content[0] : { parts: [] }; - return prefix + (content as LocalizedString).parts.join(''); - - }).join('\n')); +*/ + +/** + * Shuffles an array of indices using the Fisher-Yates shuffle algorithm + * @param length - The length of the array to create indices for + * @returns A shuffled array of indices from 0 to length-1 + */ +export function shuffleIndices(length: number): number[] { + const shuffledIndices = Array.from({ length }, (_, i) => i); + + for (let i = shuffledIndices.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffledIndices[i], shuffledIndices[j]] = [shuffledIndices[j], shuffledIndices[i]]; } -} -export const flattenSurveyItemTree = (itemTree: SurveyGroupItem): SurveySingleItem[] => { - const flatTree = new Array(); + return shuffledIndices; +} - itemTree.items.forEach(item => { - if (isSurveyGroupItem(item)) { - flatTree.push(...flattenSurveyItemTree(item)); - } else { - if (!item.type && !item.components) { - console.debug('Item without type or components - ignored: ' + JSON.stringify(item)); - return; - } - flatTree.push({ ...item }); - } - }); - return flatTree; +export function structuredCloneMethod(obj: T): T { + if (typeof structuredClone !== 'undefined') { + return structuredClone(obj); + } + // Fallback to JSON method + return JSON.parse(JSON.stringify(obj)); } diff --git a/src/validation-checkers.ts b/src/validation-checkers.ts index 7b6fc3e..e3378e4 100644 --- a/src/validation-checkers.ts +++ b/src/validation-checkers.ts @@ -1,11 +1,13 @@ -import { SurveySingleItem } from "./data_types"; +/* +TODO: add validation checkers +import { SurveyItem } from "./data_types"; interface ValidityResults { soft: boolean; hard: boolean; } -export const checkSurveyItemValidity = (item: SurveySingleItem): ValidityResults => { +export const checkSurveyItemValidity = (item: SurveyItem): ValidityResults => { const results = { soft: true, hard: true @@ -30,7 +32,7 @@ export const checkSurveyItemValidity = (item: SurveySingleItem): ValidityResults return results; } -export const checkSurveyItemsValidity = (items: SurveySingleItem[]): ValidityResults => { +export const checkSurveyItemsValidity = (items: SurveyItem[]): ValidityResults => { const results = { soft: true, hard: true @@ -49,4 +51,4 @@ export const checkSurveyItemsValidity = (items: SurveySingleItem[]): ValidityRes } } return results; -} \ No newline at end of file +} */ \ No newline at end of file diff --git a/tsdown.config.ts b/tsdown.config.ts new file mode 100644 index 0000000..55e7f98 --- /dev/null +++ b/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown/config' + +export default defineConfig({ + entry: { + index: "src/index.ts", + editor: "src/survey-editor/index.ts" + }, + copy: [ + { + from: "package.json", + to: "build/package.json" + } + ], + format: "esm", + dts: true, + outDir: "build", + sourcemap: true, +}) diff --git a/yarn.lock b/yarn.lock index 277c8a3..00b07cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10,7 +10,7 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.24.7": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.7.tgz#882fd9e09e8ee324e496bd040401c6f046ef4465" integrity sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA== @@ -18,12 +18,26 @@ "@babel/highlight" "^7.24.7" picocolors "^1.0.0" +"@babel/code-frame@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" + integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== + dependencies: + "@babel/helper-validator-identifier" "^7.27.1" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/compat-data@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.24.7.tgz#d23bbea508c3883ba8251fb4164982c36ea577ed" integrity sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw== -"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.23.9": +"@babel/compat-data@^7.27.2": + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.5.tgz#7d0658ec1a8420fc866d1df1b03bea0e79934c82" + integrity sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg== + +"@babel/core@^7.23.9": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.7.tgz#b676450141e0b52a3d43bc91da86aa608f950ac4" integrity sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g== @@ -44,7 +58,28 @@ json5 "^2.2.3" semver "^6.3.1" -"@babel/generator@^7.24.7", "@babel/generator@^7.7.2": +"@babel/core@^7.27.4": + version "7.27.4" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.4.tgz#cc1fc55d0ce140a1828d1dd2a2eba285adbfb3ce" + integrity sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.27.3" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-module-transforms" "^7.27.3" + "@babel/helpers" "^7.27.4" + "@babel/parser" "^7.27.4" + "@babel/template" "^7.27.2" + "@babel/traverse" "^7.27.4" + "@babel/types" "^7.27.3" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.7.tgz#1654d01de20ad66b4b4d99c135471bc654c55e6d" integrity sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA== @@ -54,6 +89,28 @@ "@jridgewell/trace-mapping" "^0.3.25" jsesc "^2.5.1" +"@babel/generator@^7.27.3": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.27.3.tgz#ef1c0f7cfe3b5fc8cbb9f6cc69f93441a68edefc" + integrity sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q== + dependencies: + "@babel/parser" "^7.27.3" + "@babel/types" "^7.27.3" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^3.0.2" + +"@babel/generator@^7.27.5": + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.27.5.tgz#3eb01866b345ba261b04911020cbe22dd4be8c8c" + integrity sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw== + dependencies: + "@babel/parser" "^7.27.5" + "@babel/types" "^7.27.3" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^3.0.2" + "@babel/helper-compilation-targets@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.7.tgz#4eb6c4a80d6ffeac25ab8cd9a21b5dfa48d503a9" @@ -65,6 +122,17 @@ lru-cache "^5.1.1" semver "^6.3.1" +"@babel/helper-compilation-targets@^7.27.2": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz#46a0f6efab808d51d29ce96858dd10ce8732733d" + integrity sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ== + dependencies: + "@babel/compat-data" "^7.27.2" + "@babel/helper-validator-option" "^7.27.1" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + "@babel/helper-environment-visitor@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz#4b31ba9551d1f90781ba83491dd59cf9b269f7d9" @@ -95,6 +163,14 @@ "@babel/traverse" "^7.24.7" "@babel/types" "^7.24.7" +"@babel/helper-module-imports@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204" + integrity sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w== + dependencies: + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + "@babel/helper-module-transforms@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.24.7.tgz#31b6c9a2930679498db65b685b1698bfd6c7daf8" @@ -106,11 +182,25 @@ "@babel/helper-split-export-declaration" "^7.24.7" "@babel/helper-validator-identifier" "^7.24.7" -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.24.7", "@babel/helper-plugin-utils@^7.8.0": +"@babel/helper-module-transforms@^7.27.3": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz#db0bbcfba5802f9ef7870705a7ef8788508ede02" + integrity sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg== + dependencies: + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@babel/traverse" "^7.27.3" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.8.0": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.7.tgz#98c84fe6fe3d0d3ae7bfc3a5e166a46844feb2a0" integrity sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg== +"@babel/helper-plugin-utils@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c" + integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw== + "@babel/helper-simple-access@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz#bcade8da3aec8ed16b9c4953b74e506b51b5edb3" @@ -131,16 +221,31 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz#4d2d0f14820ede3b9807ea5fc36dfc8cd7da07f2" integrity sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg== +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + "@babel/helper-validator-identifier@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz#75b889cfaf9e35c2aaf42cf0d72c8e91719251db" integrity sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w== +"@babel/helper-validator-identifier@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8" + integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== + "@babel/helper-validator-option@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.24.7.tgz#24c3bb77c7a425d1742eec8fb433b5a1b38e62f6" integrity sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw== +"@babel/helper-validator-option@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" + integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== + "@babel/helpers@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.24.7.tgz#aa2ccda29f62185acb5d42fb4a3a1b1082107416" @@ -149,6 +254,14 @@ "@babel/template" "^7.24.7" "@babel/types" "^7.24.7" +"@babel/helpers@^7.27.4": + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.27.6.tgz#6456fed15b2cb669d2d1fabe84b66b34991d812c" + integrity sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug== + dependencies: + "@babel/template" "^7.27.2" + "@babel/types" "^7.27.6" + "@babel/highlight@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.7.tgz#a05ab1df134b286558aae0ed41e6c5f731bf409d" @@ -159,11 +272,25 @@ js-tokens "^4.0.0" picocolors "^1.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.24.7": +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.7.tgz#9a5226f92f0c5c8ead550b750f5608e766c8ce85" integrity sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw== +"@babel/parser@^7.27.2", "@babel/parser@^7.27.4", "@babel/parser@^7.27.5": + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.5.tgz#ed22f871f110aa285a6fd934a0efed621d118826" + integrity sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg== + dependencies: + "@babel/types" "^7.27.3" + +"@babel/parser@^7.27.3": + version "7.27.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.4.tgz#f92e89e4f51847be05427285836fc88341c956df" + integrity sha512-BRmLHGwpUqLFR2jzx9orBuX/ABDkj2jLKOXrHDTN2aOKL+jFDDKaRNo9nyYsIl9h/UE/7lMKdDjKQQyxKKDZ7g== + dependencies: + "@babel/types" "^7.27.3" + "@babel/plugin-syntax-async-generators@^7.8.4": version "7.8.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" @@ -178,14 +305,28 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-class-properties@^7.8.3": +"@babel/plugin-syntax-class-properties@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-syntax-import-meta@^7.8.3": +"@babel/plugin-syntax-class-static-block@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz#195df89b146b4b78b3bf897fd7a257c84659d406" + integrity sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-import-attributes@^7.24.7": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz#34c017d54496f9b11b61474e7ea3dfd5563ffe07" + integrity sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-syntax-import-meta@^7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== @@ -199,14 +340,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-jsx@^7.7.2": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.7.tgz#39a1fa4a7e3d3d7f34e2acc6be585b718d30e02d" - integrity sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ== +"@babel/plugin-syntax-jsx@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz#2f9beb5eff30fa507c5532d107daac7b888fa34c" + integrity sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w== dependencies: - "@babel/helper-plugin-utils" "^7.24.7" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-syntax-logical-assignment-operators@^7.8.3": +"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== @@ -220,7 +361,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-numeric-separator@^7.8.3": +"@babel/plugin-syntax-numeric-separator@^7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== @@ -248,28 +389,28 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-top-level-await@^7.8.3": +"@babel/plugin-syntax-private-property-in-object@^7.14.5": version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" - integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz#0dc6671ec0ea22b6e94a1114f857970cd39de1ad" + integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== dependencies: "@babel/helper-plugin-utils" "^7.14.5" -"@babel/plugin-syntax-typescript@^7.7.2": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.7.tgz#58d458271b4d3b6bb27ee6ac9525acbb259bad1c" - integrity sha512-c/+fVeJBB0FeKsFvwytYiUD+LBvhHjGSI0g446PRGdSVGZLRNArBUno2PETbAly3tpiNAQR5XaZ+JslxkotsbA== +"@babel/plugin-syntax-top-level-await@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" + integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== dependencies: - "@babel/helper-plugin-utils" "^7.24.7" + "@babel/helper-plugin-utils" "^7.14.5" -"@babel/runtime@^7.21.0": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.7.tgz#f4f0d5530e8dbdf59b3451b9b3e594b6ba082e12" - integrity sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw== +"@babel/plugin-syntax-typescript@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz#5147d29066a793450f220c63fa3a9431b7e6dd18" + integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ== dependencies: - regenerator-runtime "^0.14.0" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/template@^7.24.7", "@babel/template@^7.3.3": +"@babel/template@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.7.tgz#02efcee317d0609d2c07117cb70ef8fb17ab7315" integrity sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig== @@ -278,6 +419,15 @@ "@babel/parser" "^7.24.7" "@babel/types" "^7.24.7" +"@babel/template@^7.27.2": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" + integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/parser" "^7.27.2" + "@babel/types" "^7.27.1" + "@babel/traverse@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.7.tgz#de2b900163fa741721ba382163fe46a936c40cf5" @@ -294,7 +444,20 @@ debug "^4.3.1" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.24.7", "@babel/types@^7.3.3": +"@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3", "@babel/traverse@^7.27.4": + version "7.27.4" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.27.4.tgz#b0045ac7023c8472c3d35effd7cc9ebd638da6ea" + integrity sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.27.3" + "@babel/parser" "^7.27.4" + "@babel/template" "^7.27.2" + "@babel/types" "^7.27.3" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.7.tgz#6027fe12bc1aa724cd32ab113fb7f1988f1f66f2" integrity sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q== @@ -303,11 +466,155 @@ "@babel/helper-validator-identifier" "^7.24.7" to-fast-properties "^2.0.0" +"@babel/types@^7.27.1", "@babel/types@^7.27.6": + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.6.tgz#a434ca7add514d4e646c80f7375c0aa2befc5535" + integrity sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + +"@babel/types@^7.27.3": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.3.tgz#c0257bedf33aad6aad1f406d35c44758321eb3ec" + integrity sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@emnapi/core@^1.4.3": + version "1.4.3" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.4.3.tgz#9ac52d2d5aea958f67e52c40a065f51de59b77d6" + integrity sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g== + dependencies: + "@emnapi/wasi-threads" "1.0.2" + tslib "^2.4.0" + +"@emnapi/runtime@^1.4.3": + version "1.4.3" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.4.3.tgz#c0564665c80dc81c448adac23f9dfbed6c838f7d" + integrity sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz#977f44f844eac7d6c138a415a123818c655f874c" + integrity sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA== + dependencies: + tslib "^2.4.0" + +"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz#607084630c6c033992a082de6e6fbc1a8b52175a" + integrity sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.1": + version "4.12.1" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" + integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== + +"@eslint/config-array@^0.20.1": + version "0.20.1" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.20.1.tgz#454f89be82b0e5b1ae872c154c7e2f3dd42c3979" + integrity sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw== + dependencies: + "@eslint/object-schema" "^2.1.6" + debug "^4.3.1" + minimatch "^3.1.2" + +"@eslint/config-helpers@^0.2.1": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.2.2.tgz#3779f76b894de3a8ec4763b79660e6d54d5b1010" + integrity sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg== + +"@eslint/core@^0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.14.0.tgz#326289380968eaf7e96f364e1e4cf8f3adf2d003" + integrity sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/eslintrc@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz#e55f7f1dd400600dd066dbba349c4c0bac916964" + integrity sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^10.0.1" + globals "^14.0.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@9.29.0": + version "9.29.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.29.0.tgz#dc6fd117c19825f8430867a662531da36320fe56" + integrity sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ== + +"@eslint/object-schema@^2.1.6": + version "2.1.6" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f" + integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA== + +"@eslint/plugin-kit@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz#b71b037b2d4d68396df04a8c35a49481e5593067" + integrity sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w== + dependencies: + "@eslint/core" "^0.14.0" + levn "^0.4.1" + +"@humanfs/core@^0.19.1": + version "0.19.1" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" + integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + +"@humanfs/node@^0.16.6": + version "0.16.6" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.6.tgz#ee2a10eaabd1131987bf0488fd9b820174cd765e" + integrity sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw== + dependencies: + "@humanfs/core" "^0.19.1" + "@humanwhocodes/retry" "^0.3.0" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.3.0": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.3.1.tgz#c72a5c76a9fbaf3488e231b13dc52c0da7bab42a" + integrity sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA== + +"@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -324,197 +631,270 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== -"@jest/console@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/console/-/console-29.7.0.tgz#cd4822dbdb84529265c5a2bdb529a3c9cc950ffc" - integrity sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg== +"@jest/console@30.0.2": + version "30.0.2" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-30.0.2.tgz#e2bf6c7703d45f9824d77c7332388c3e1685afd7" + integrity sha512-krGElPU0FipAqpVZ/BRZOy0MZh/ARdJ0Nj+PiH1ykFY1+VpBlYNLjdjVA5CFKxnKR6PFqFutO4Z7cdK9BlGiDA== dependencies: - "@jest/types" "^29.6.3" + "@jest/types" "30.0.1" "@types/node" "*" - chalk "^4.0.0" - jest-message-util "^29.7.0" - jest-util "^29.7.0" + chalk "^4.1.2" + jest-message-util "30.0.2" + jest-util "30.0.2" slash "^3.0.0" -"@jest/core@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/core/-/core-29.7.0.tgz#b6cccc239f30ff36609658c5a5e2291757ce448f" - integrity sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg== - dependencies: - "@jest/console" "^29.7.0" - "@jest/reporters" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" +"@jest/core@30.0.2": + version "30.0.2" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-30.0.2.tgz#c84c85baac55e6fa85b491edc4280425631951c7" + integrity sha512-mUMFdDtYWu7la63NxlyNIhgnzynszxunXWrtryR7bV24jV9hmi7XCZTzZHaLJjcBU66MeUAPZ81HjwASVpYhYQ== + dependencies: + "@jest/console" "30.0.2" + "@jest/pattern" "30.0.1" + "@jest/reporters" "30.0.2" + "@jest/test-result" "30.0.2" + "@jest/transform" "30.0.2" + "@jest/types" "30.0.1" "@types/node" "*" - ansi-escapes "^4.2.1" - chalk "^4.0.0" - ci-info "^3.2.0" - exit "^0.1.2" - graceful-fs "^4.2.9" - jest-changed-files "^29.7.0" - jest-config "^29.7.0" - jest-haste-map "^29.7.0" - jest-message-util "^29.7.0" - jest-regex-util "^29.6.3" - jest-resolve "^29.7.0" - jest-resolve-dependencies "^29.7.0" - jest-runner "^29.7.0" - jest-runtime "^29.7.0" - jest-snapshot "^29.7.0" - jest-util "^29.7.0" - jest-validate "^29.7.0" - jest-watcher "^29.7.0" - micromatch "^4.0.4" - pretty-format "^29.7.0" + ansi-escapes "^4.3.2" + chalk "^4.1.2" + ci-info "^4.2.0" + exit-x "^0.2.2" + graceful-fs "^4.2.11" + jest-changed-files "30.0.2" + jest-config "30.0.2" + jest-haste-map "30.0.2" + jest-message-util "30.0.2" + jest-regex-util "30.0.1" + jest-resolve "30.0.2" + jest-resolve-dependencies "30.0.2" + jest-runner "30.0.2" + jest-runtime "30.0.2" + jest-snapshot "30.0.2" + jest-util "30.0.2" + jest-validate "30.0.2" + jest-watcher "30.0.2" + micromatch "^4.0.8" + pretty-format "30.0.2" slash "^3.0.0" - strip-ansi "^6.0.0" -"@jest/environment@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.7.0.tgz#24d61f54ff1f786f3cd4073b4b94416383baf2a7" - integrity sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw== +"@jest/diff-sequences@30.0.0": + version "30.0.0" + resolved "https://registry.yarnpkg.com/@jest/diff-sequences/-/diff-sequences-30.0.0.tgz#402d27d14e9d5161dedfca98bf181018a8931eb1" + integrity sha512-xMbtoCeKJDto86GW6AiwVv7M4QAuI56R7dVBr1RNGYbOT44M2TIzOiske2RxopBqkumDY+A1H55pGvuribRY9A== + +"@jest/diff-sequences@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz#0ededeae4d071f5c8ffe3678d15f3a1be09156be" + integrity sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw== + +"@jest/environment@30.0.2": + version "30.0.2" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-30.0.2.tgz#1b0d055070e97f697e9edb25059e9435221cbe65" + integrity sha512-hRLhZRJNxBiOhxIKSq2UkrlhMt3/zVFQOAi5lvS8T9I03+kxsbflwHJEF+eXEYXCrRGRhHwECT7CDk6DyngsRA== dependencies: - "@jest/fake-timers" "^29.7.0" - "@jest/types" "^29.6.3" + "@jest/fake-timers" "30.0.2" + "@jest/types" "30.0.1" "@types/node" "*" - jest-mock "^29.7.0" + jest-mock "30.0.2" + +"@jest/expect-utils@30.0.0": + version "30.0.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-30.0.0.tgz#118d41d9df420db61d307308848a9e12f0fc1fad" + integrity sha512-UiWfsqNi/+d7xepfOv8KDcbbzcYtkWBe3a3kVDtg6M1kuN6CJ7b4HzIp5e1YHrSaQaVS8sdCoyCMCZClTLNKFQ== + dependencies: + "@jest/get-type" "30.0.0" -"@jest/expect-utils@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz#023efe5d26a8a70f21677d0a1afc0f0a44e3a1c6" - integrity sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA== +"@jest/expect-utils@30.0.2": + version "30.0.2" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-30.0.2.tgz#d065f68c128cec526540193d88f2fc64c3d4f971" + integrity sha512-FHF2YdtFBUQOo0/qdgt+6UdBFcNPF/TkVzcc+4vvf8uaBzUlONytGBeeudufIHHW1khRfM1sBbRT1VCK7n/0dQ== dependencies: - jest-get-type "^29.6.3" + "@jest/get-type" "30.0.1" -"@jest/expect@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-29.7.0.tgz#76a3edb0cb753b70dfbfe23283510d3d45432bf2" - integrity sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ== +"@jest/expect@30.0.2": + version "30.0.2" + resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-30.0.2.tgz#b3d5adec28f3884d6fd0746c4b5d0d2473e9e212" + integrity sha512-blWRFPjv2cVfh42nLG6L3xIEbw+bnuiZYZDl/BZlsNG/i3wKV6FpPZ2EPHguk7t5QpLaouIu+7JmYO4uBR6AOg== dependencies: - expect "^29.7.0" - jest-snapshot "^29.7.0" + expect "30.0.2" + jest-snapshot "30.0.2" -"@jest/fake-timers@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.7.0.tgz#fd91bf1fffb16d7d0d24a426ab1a47a49881a565" - integrity sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ== +"@jest/fake-timers@30.0.2": + version "30.0.2" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-30.0.2.tgz#ec758b28ae6f63a49eda9e8d6af274d152d37c09" + integrity sha512-jfx0Xg7l0gmphTY9UKm5RtH12BlLYj/2Plj6wXjVW5Era4FZKfXeIvwC67WX+4q8UCFxYS20IgnMcFBcEU0DtA== + dependencies: + "@jest/types" "30.0.1" + "@sinonjs/fake-timers" "^13.0.0" + "@types/node" "*" + jest-message-util "30.0.2" + jest-mock "30.0.2" + jest-util "30.0.2" + +"@jest/get-type@30.0.0": + version "30.0.0" + resolved "https://registry.yarnpkg.com/@jest/get-type/-/get-type-30.0.0.tgz#59dcb5a9cbd9eb0004d3a2ed2fa9c9c3abfbf005" + integrity sha512-VZWMjrBzqfDKngQ7sUctKeLxanAbsBFoZnPxNIG6CmxK7Gv6K44yqd0nzveNIBfuhGZMmk1n5PGbvdSTOu0yTg== + +"@jest/get-type@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/get-type/-/get-type-30.0.1.tgz#0d32f1bbfba511948ad247ab01b9007724fc9f52" + integrity sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw== + +"@jest/globals@30.0.2": + version "30.0.2" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-30.0.2.tgz#3b401bb7cb8cc0a00476630298747a38e40a6fc1" + integrity sha512-DwTtus9jjbG7b6jUdkcVdptf0wtD1v153A+PVwWB/zFwXhqu6hhtSd+uq88jofMhmYPtkmPmVGUBRNCZEKXn+w== + dependencies: + "@jest/environment" "30.0.2" + "@jest/expect" "30.0.2" + "@jest/types" "30.0.1" + jest-mock "30.0.2" + +"@jest/pattern@30.0.0": + version "30.0.0" + resolved "https://registry.yarnpkg.com/@jest/pattern/-/pattern-30.0.0.tgz#2d1f04c8b64b31f1bfa71ccb60593a4415d0d452" + integrity sha512-k+TpEThzLVXMkbdxf8KHjZ83Wl+G54ytVJoDIGWwS96Ql4xyASRjc6SU1hs5jHVql+hpyK9G8N7WuFhLpGHRpQ== dependencies: - "@jest/types" "^29.6.3" - "@sinonjs/fake-timers" "^10.0.2" "@types/node" "*" - jest-message-util "^29.7.0" - jest-mock "^29.7.0" - jest-util "^29.7.0" + jest-regex-util "30.0.0" -"@jest/globals@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.7.0.tgz#8d9290f9ec47ff772607fa864ca1d5a2efae1d4d" - integrity sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ== +"@jest/pattern@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/pattern/-/pattern-30.0.1.tgz#d5304147f49a052900b4b853dedb111d080e199f" + integrity sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA== dependencies: - "@jest/environment" "^29.7.0" - "@jest/expect" "^29.7.0" - "@jest/types" "^29.6.3" - jest-mock "^29.7.0" + "@types/node" "*" + jest-regex-util "30.0.1" -"@jest/reporters@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-29.7.0.tgz#04b262ecb3b8faa83b0b3d321623972393e8f4c7" - integrity sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg== +"@jest/reporters@30.0.2": + version "30.0.2" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-30.0.2.tgz#e804435ab77cd05b7e8732b91006cd00bd822399" + integrity sha512-l4QzS/oKf57F8WtPZK+vvF4Io6ukplc6XgNFu4Hd/QxaLEO9f+8dSFzUua62Oe0HKlCUjKHpltKErAgDiMJKsA== dependencies: "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" - "@jridgewell/trace-mapping" "^0.3.18" + "@jest/console" "30.0.2" + "@jest/test-result" "30.0.2" + "@jest/transform" "30.0.2" + "@jest/types" "30.0.1" + "@jridgewell/trace-mapping" "^0.3.25" "@types/node" "*" - chalk "^4.0.0" - collect-v8-coverage "^1.0.0" - exit "^0.1.2" - glob "^7.1.3" - graceful-fs "^4.2.9" + chalk "^4.1.2" + collect-v8-coverage "^1.0.2" + exit-x "^0.2.2" + glob "^10.3.10" + graceful-fs "^4.2.11" istanbul-lib-coverage "^3.0.0" istanbul-lib-instrument "^6.0.0" istanbul-lib-report "^3.0.0" - istanbul-lib-source-maps "^4.0.0" + istanbul-lib-source-maps "^5.0.0" istanbul-reports "^3.1.3" - jest-message-util "^29.7.0" - jest-util "^29.7.0" - jest-worker "^29.7.0" + jest-message-util "30.0.2" + jest-util "30.0.2" + jest-worker "30.0.2" slash "^3.0.0" - string-length "^4.0.1" - strip-ansi "^6.0.0" + string-length "^4.0.2" v8-to-istanbul "^9.0.1" -"@jest/schemas@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" - integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== +"@jest/schemas@30.0.0": + version "30.0.0" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-30.0.0.tgz#427b862696c65ea6f6a138a9221326519877555f" + integrity sha512-NID2VRyaEkevCRz6badhfqYwri/RvMbiHY81rk3AkK/LaiB0LSxi1RdVZ7MpZdTjNugtZeGfpL0mLs9Kp3MrQw== dependencies: - "@sinclair/typebox" "^0.27.8" + "@sinclair/typebox" "^0.34.0" -"@jest/source-map@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-29.6.3.tgz#d90ba772095cf37a34a5eb9413f1b562a08554c4" - integrity sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw== +"@jest/schemas@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-30.0.1.tgz#27c00d707d480ece0c19126af97081a1af3bc46e" + integrity sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w== dependencies: - "@jridgewell/trace-mapping" "^0.3.18" - callsites "^3.0.0" - graceful-fs "^4.2.9" - -"@jest/test-result@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-29.7.0.tgz#8db9a80aa1a097bb2262572686734baed9b1657c" - integrity sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA== - dependencies: - "@jest/console" "^29.7.0" - "@jest/types" "^29.6.3" - "@types/istanbul-lib-coverage" "^2.0.0" - collect-v8-coverage "^1.0.0" - -"@jest/test-sequencer@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz#6cef977ce1d39834a3aea887a1726628a6f072ce" - integrity sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw== - dependencies: - "@jest/test-result" "^29.7.0" - graceful-fs "^4.2.9" - jest-haste-map "^29.7.0" + "@sinclair/typebox" "^0.34.0" + +"@jest/snapshot-utils@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/snapshot-utils/-/snapshot-utils-30.0.1.tgz#536108aa6b74858d758ae3b5229518c3d818bd68" + integrity sha512-6Dpv7vdtoRiISEFwYF8/c7LIvqXD7xDXtLPNzC2xqAfBznKip0MQM+rkseKwUPUpv2PJ7KW/YsnwWXrIL2xF+A== + dependencies: + "@jest/types" "30.0.1" + chalk "^4.1.2" + graceful-fs "^4.2.11" + natural-compare "^1.4.0" + +"@jest/source-map@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-30.0.1.tgz#305ebec50468f13e658b3d5c26f85107a5620aaa" + integrity sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg== + dependencies: + "@jridgewell/trace-mapping" "^0.3.25" + callsites "^3.1.0" + graceful-fs "^4.2.11" + +"@jest/test-result@30.0.2": + version "30.0.2" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-30.0.2.tgz#786849e33da6060381c508986fa7309ff855a367" + integrity sha512-KKMuBKkkZYP/GfHMhI+cH2/P3+taMZS3qnqqiPC1UXZTJskkCS+YU/ILCtw5anw1+YsTulDHFpDo70mmCedW8w== + dependencies: + "@jest/console" "30.0.2" + "@jest/types" "30.0.1" + "@types/istanbul-lib-coverage" "^2.0.6" + collect-v8-coverage "^1.0.2" + +"@jest/test-sequencer@30.0.2": + version "30.0.2" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-30.0.2.tgz#2693692d285b1c929ed353f7f0b7cbea51c57515" + integrity sha512-fbyU5HPka0rkalZ3MXVvq0hwZY8dx3Y6SCqR64zRmh+xXlDeFl0IdL4l9e7vp4gxEXTYHbwLFA1D+WW5CucaSw== + dependencies: + "@jest/test-result" "30.0.2" + graceful-fs "^4.2.11" + jest-haste-map "30.0.2" slash "^3.0.0" -"@jest/transform@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-29.7.0.tgz#df2dd9c346c7d7768b8a06639994640c642e284c" - integrity sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw== +"@jest/transform@30.0.2": + version "30.0.2" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-30.0.2.tgz#62ba84fcc2389ab751e7ec923958c9b1163d90c3" + integrity sha512-kJIuhLMTxRF7sc0gPzPtCDib/V9KwW3I2U25b+lYCYMVqHHSrcZopS8J8H+znx9yixuFv+Iozl8raLt/4MoxrA== dependencies: - "@babel/core" "^7.11.6" - "@jest/types" "^29.6.3" - "@jridgewell/trace-mapping" "^0.3.18" - babel-plugin-istanbul "^6.1.1" - chalk "^4.0.0" + "@babel/core" "^7.27.4" + "@jest/types" "30.0.1" + "@jridgewell/trace-mapping" "^0.3.25" + babel-plugin-istanbul "^7.0.0" + chalk "^4.1.2" convert-source-map "^2.0.0" fast-json-stable-stringify "^2.1.0" - graceful-fs "^4.2.9" - jest-haste-map "^29.7.0" - jest-regex-util "^29.6.3" - jest-util "^29.7.0" - micromatch "^4.0.4" - pirates "^4.0.4" + graceful-fs "^4.2.11" + jest-haste-map "30.0.2" + jest-regex-util "30.0.1" + jest-util "30.0.2" + micromatch "^4.0.8" + pirates "^4.0.7" slash "^3.0.0" - write-file-atomic "^4.0.2" + write-file-atomic "^5.0.1" -"@jest/types@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.3.tgz#1131f8cf634e7e84c5e77bab12f052af585fba59" - integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== +"@jest/types@30.0.0": + version "30.0.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.0.0.tgz#7afb1d34937f722f667b621eb9c653f0f8fda07e" + integrity sha512-1Nox8mAL52PKPfEnUQWBvKU/bp8FTT6AiDu76bFDEJj/qsRFSAVSldfCH3XYMqialti2zHXKvD5gN0AaHc0yKA== dependencies: - "@jest/schemas" "^29.6.3" - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" + "@jest/pattern" "30.0.0" + "@jest/schemas" "30.0.0" + "@types/istanbul-lib-coverage" "^2.0.6" + "@types/istanbul-reports" "^3.0.4" "@types/node" "*" - "@types/yargs" "^17.0.8" - chalk "^4.0.0" + "@types/yargs" "^17.0.33" + chalk "^4.1.2" + +"@jest/types@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.0.1.tgz#a46df6a99a416fa685740ac4264b9f9cd7da1598" + integrity sha512-HGwoYRVF0QSKJu1ZQX0o5ZrUrrhj0aOOFA8hXrumD7SIzjouevhawbTjmXdwOmURdGluU9DM/XvGm3NyFoiQjw== + dependencies: + "@jest/pattern" "30.0.1" + "@jest/schemas" "30.0.1" + "@types/istanbul-lib-coverage" "^2.0.6" + "@types/istanbul-reports" "^3.0.4" + "@types/node" "*" + "@types/yargs" "^17.0.33" + chalk "^4.1.2" "@jridgewell/gen-mapping@^0.3.5": version "0.3.5" @@ -540,7 +920,7 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== -"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": version "0.3.25" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== @@ -548,6 +928,15 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@napi-rs/wasm-runtime@^0.2.10", "@napi-rs/wasm-runtime@^0.2.11": + version "0.2.11" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz#192c1610e1625048089ab4e35bc0649ce478500e" + integrity sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA== + dependencies: + "@emnapi/core" "^1.4.3" + "@emnapi/runtime" "^1.4.3" + "@tybys/wasm-util" "^0.9.0" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -569,68 +958,127 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@rollup/plugin-commonjs@^21.0.1": - version "21.1.0" - resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-21.1.0.tgz#45576d7b47609af2db87f55a6d4b46e44fc3a553" - integrity sha512-6ZtHx3VHIp2ReNNDxHjuUml6ur+WcQ28N1yHgCQwsbNkQg2suhxGMDQGJOn/KuDxKtd1xuZP5xSTwBA4GQ8hbA== - dependencies: - "@rollup/pluginutils" "^3.1.0" - commondir "^1.0.1" - estree-walker "^2.0.1" - glob "^7.1.6" - is-reference "^1.2.1" - magic-string "^0.25.7" - resolve "^1.17.0" - -"@rollup/plugin-node-resolve@^13.0.6": - version "13.3.0" - resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.3.0.tgz#da1c5c5ce8316cef96a2f823d111c1e4e498801c" - integrity sha512-Lus8rbUo1eEcnS4yTFKLZrVumLPY+YayBdWXgFSHYhTT2iJbMhoaaBL3xl5NCdeRytErGr8tZ0L71BMRmnlwSw== - dependencies: - "@rollup/pluginutils" "^3.1.0" - "@types/resolve" "1.17.1" - deepmerge "^4.2.2" - is-builtin-module "^3.1.0" - is-module "^1.0.0" - resolve "^1.19.0" - -"@rollup/pluginutils@^3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b" - integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg== - dependencies: - "@types/estree" "0.0.39" - estree-walker "^1.0.1" - picomatch "^2.2.2" +"@oxc-project/runtime@=0.72.3": + version "0.72.3" + resolved "https://registry.yarnpkg.com/@oxc-project/runtime/-/runtime-0.72.3.tgz#9a332ae8ad9fcbc2345c4040ab39e03b03b8ffaa" + integrity sha512-FtOS+0v7rZcnjXzYTTqv1vu/KDptD1UztFgoZkYBGe/6TcNFm+SP/jQoLvzau1SPir95WgDOBOUm2Gmsm+bQag== -"@rollup/pluginutils@^4.1.2": - version "4.2.1" - resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d" - integrity sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ== - dependencies: - estree-walker "^2.0.1" - picomatch "^2.2.2" +"@oxc-project/types@=0.72.3": + version "0.72.3" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.72.3.tgz#2463a4ba6c57e25c72029981909316b77ada59cd" + integrity sha512-CfAC4wrmMkUoISpQkFAIfMVvlPfQV3xg7ZlcqPXPOIMQhdKIId44G8W0mCPgtpWdFFAyJ+SFtiM+9vbyCkoVng== -"@sinclair/typebox@^0.27.8": - version "0.27.8" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" - integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== -"@sinonjs/commons@^3.0.0": +"@pkgr/core@^0.2.4": + version "0.2.7" + resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.7.tgz#eb5014dfd0b03e7f3ba2eeeff506eed89b028058" + integrity sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg== + +"@quansync/fs@^0.1.1": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@quansync/fs/-/fs-0.1.3.tgz#2328aec83fef343b72c73ca77ca08e1e12bcf9d9" + integrity sha512-G0OnZbMWEs5LhDyqy2UL17vGhSVHkQIfVojMtEWVenvj0V5S84VBgy86kJIuNsGDp2p7sTKlpSIpBUWdC35OKg== + dependencies: + quansync "^0.2.10" + +"@rolldown/binding-darwin-arm64@1.0.0-beta.15": + version "1.0.0-beta.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.15.tgz#255bfe798d47bb479dcf7c81bf796d9f9745a953" + integrity sha512-YInZppDBLp5DadbJZGc7xBfDrMCSj3P6i2rPlvOCMlvjBQxJi2kX8Jquh+LufsWUiHD3JsvvH5EuUUc/tF5fkA== + +"@rolldown/binding-darwin-x64@1.0.0-beta.15": + version "1.0.0-beta.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.15.tgz#6caaf21aea142ad4e37b7d3f9cd3dedb63ce38e5" + integrity sha512-Zwv8KHU/XdVwLseHG6slJ0FAFklPpiO0sjNvhrcMp1X3F2ajPzUdIO8Cnu3KLmX1GWVSvu6q1kyARLUqPvlh7Q== + +"@rolldown/binding-freebsd-x64@1.0.0-beta.15": + version "1.0.0-beta.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.15.tgz#6e91ada6eaa63111c5465d2c29e04462f2cfafc6" + integrity sha512-FwhNC23Fz9ldHW1/rX4QaoQe4kyOybCgxO9eglue3cbb3ol28KWpQl3xJfvXc9+O6PDefAs4oFBCbtTh8seiUw== + +"@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.15": + version "1.0.0-beta.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.15.tgz#d3d0c775af80d4f4b9f50adce778b19ac79bcf38" + integrity sha512-E60pNliWl4j7EFEVX2oeJZ5VzR+NG6fvDJoqfqRfCl8wtKIf9E1WPWVQIrT+zkz+Fhc5op8g7h25z6rtxsDy9g== + +"@rolldown/binding-linux-arm64-gnu@1.0.0-beta.15": + version "1.0.0-beta.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.15.tgz#6cdea96e9558eeed75a749ba3b680804a2a7fb94" + integrity sha512-d+qo1LZ/a3EcQW08byIIZy0PBthmG/7dr69pifmNIet/azWR8jbceQaRFFczVc/NwVV3fsZDCmjG8mgJzsNEAg== + +"@rolldown/binding-linux-arm64-musl@1.0.0-beta.15": + version "1.0.0-beta.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.15.tgz#441b703d17f01302b9b208b57b0b9eba0a5f5e8a" + integrity sha512-P1hbtYF+5ftJI2Ergs4iARbAk6Xd6WnTQb3CF9kjN3KfJTsRYdo5/fvU8Lz/gzhZVvkCXXH3NxDd9308UBO8cw== + +"@rolldown/binding-linux-x64-gnu@1.0.0-beta.15": + version "1.0.0-beta.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.15.tgz#f5d54182fa09f5c6292f039a7f37cd67e530410f" + integrity sha512-Q9NM9uMFN9cjcrW7gd9U087B5WzkEj9dQQHOgoENZSy+vYJYS2fINCIG40ljEVC6jXmVrJgUhJKv7elRZM1nng== + +"@rolldown/binding-linux-x64-musl@1.0.0-beta.15": + version "1.0.0-beta.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.15.tgz#230b6d74c7869b448d67ca9e3c9fa7a7c4cfc66c" + integrity sha512-1tuCWuR8gx9PyW2pxAx2ZqnOnwhoY6NWBVP6ZmrjCKQ16NclYc61BzegFXSdugCy8w1QpBPT8/c5oh2W4E5aeA== + +"@rolldown/binding-wasm32-wasi@1.0.0-beta.15": + version "1.0.0-beta.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.15.tgz#fbc31232a97ce022b670f78cfabe33ba3215ba0e" + integrity sha512-zrSeYrpTf27hRxMLh0qpkCoWgzRKG8EyR6o09Zt9xkqCOeE5tEK/S3jV1Nii9WSqVCWFRA+OYxKzMNoykV590g== + dependencies: + "@napi-rs/wasm-runtime" "^0.2.10" + +"@rolldown/binding-win32-arm64-msvc@1.0.0-beta.15": + version "1.0.0-beta.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.15.tgz#63e43abaac807313bfcdbfbac4887e174c4b35ce" + integrity sha512-diR41DsMUnkvb9hvW8vuIrA0WaacAN1fu6lPseXhYifAOZN6kvxEwKn7Xib8i0zjdrYErLv7GNSQ48W+xiNOnA== + +"@rolldown/binding-win32-ia32-msvc@1.0.0-beta.15": + version "1.0.0-beta.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.15.tgz#b48f44dc2989c20b87d96c00f090e96ed44e2d78" + integrity sha512-oCbbcDC3Lk8YgdxCkG23UqVrvXVvllIBgmmwq89bhq5okPP899OI/P+oTTDsUTbhljzNq1pH8a+mR6YBxAFfvw== + +"@rolldown/binding-win32-x64-msvc@1.0.0-beta.15": + version "1.0.0-beta.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.15.tgz#7f819d61f2643da8844e6c33e5d3dd7ba433e3e5" + integrity sha512-w5hVsOv3dzKo10wAXizmnDvUo1yasn/ps+mcn9H9TiJ/GeRE5/15Y6hG6vUQYRQNLVbYRHUt2qG0MyOoasPcHg== + +"@rolldown/pluginutils@1.0.0-beta.15": + version "1.0.0-beta.15" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.15.tgz#fd82a9bb7d5349fd31964985c595b260d5709d74" + integrity sha512-lvFtIbidq5EqyAAeiVk41ZNjGRgUoGRBIuqpe1VRJ7R8Av7TLAgGWAwGlHNhO7MFkl7MNRX350CsTtIWIYkNIQ== + +"@sinclair/typebox@^0.34.0": + version "0.34.33" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.34.33.tgz#10ab3f1261ed9e754660250fad3e69cca1fa44b2" + integrity sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g== + +"@sinonjs/commons@^3.0.1": version "3.0.1" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== dependencies: type-detect "4.0.8" -"@sinonjs/fake-timers@^10.0.2": - version "10.3.0" - resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66" - integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA== +"@sinonjs/fake-timers@^13.0.0": + version "13.0.5" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz#36b9dbc21ad5546486ea9173d6bea063eb1717d5" + integrity sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw== dependencies: - "@sinonjs/commons" "^3.0.0" + "@sinonjs/commons" "^3.0.1" -"@types/babel__core@^7.1.14": +"@tybys/wasm-util@^0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.9.0.tgz#3e75eb00604c8d6db470bf18c37b7d984a0e3355" + integrity sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw== + dependencies: + tslib "^2.4.0" + +"@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== @@ -656,46 +1104,19 @@ "@babel/parser" "^7.1.0" "@babel/types" "^7.0.0" -"@types/babel__traverse@*", "@types/babel__traverse@^7.0.6": +"@types/babel__traverse@*": version "7.20.6" resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.6.tgz#8dc9f0ae0f202c08d8d4dab648912c8d6038e3f7" integrity sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg== dependencies: "@babel/types" "^7.20.7" -"@types/estree@*": - version "1.0.5" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" - integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== - -"@types/estree@0.0.39": - version "0.0.39" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" - integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== - -"@types/fs-extra@^8.0.1": - version "8.1.5" - resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-8.1.5.tgz#33aae2962d3b3ec9219b5aca2555ee00274f5927" - integrity sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ== - dependencies: - "@types/node" "*" - -"@types/glob@^7.1.1": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" - integrity sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA== - dependencies: - "@types/minimatch" "*" - "@types/node" "*" - -"@types/graceful-fs@^4.1.3": - version "4.1.9" - resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" - integrity sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ== - dependencies: - "@types/node" "*" +"@types/estree@^1.0.6": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== @@ -707,25 +1128,25 @@ dependencies: "@types/istanbul-lib-coverage" "*" -"@types/istanbul-reports@^3.0.0": +"@types/istanbul-reports@^3.0.4": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz#0f03e3d2f670fbdac586e34b433783070cc16f54" integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ== dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@^29.2.0": - version "29.5.12" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.12.tgz#7f7dc6eb4cf246d2474ed78744b05d06ce025544" - integrity sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw== +"@types/jest@^30.0.0": + version "30.0.0" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-30.0.0.tgz#5e85ae568006712e4ad66f25433e9bdac8801f1d" + integrity sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA== dependencies: - expect "^29.0.0" - pretty-format "^29.0.0" + expect "^30.0.0" + pretty-format "^30.0.0" -"@types/minimatch@*": - version "5.1.2" - resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" - integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== "@types/node@*": version "20.14.2" @@ -734,14 +1155,7 @@ dependencies: undici-types "~5.26.4" -"@types/resolve@1.17.1": - version "1.17.1" - resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" - integrity sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw== - dependencies: - "@types/node" "*" - -"@types/stack-utils@^2.0.0": +"@types/stack-utils@^2.0.3": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== @@ -751,31 +1165,223 @@ resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" integrity sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ== -"@types/yargs@^17.0.8": - version "17.0.32" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.32.tgz#030774723a2f7faafebf645f4e5a48371dca6229" - integrity sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog== +"@types/yargs@^17.0.33": + version "17.0.33" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.33.tgz#8c32303da83eec050a84b3c7ae7b9f922d13e32d" + integrity sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA== dependencies: "@types/yargs-parser" "*" -"@yarn-tool/resolve-package@^1.0.40": - version "1.0.47" - resolved "https://registry.yarnpkg.com/@yarn-tool/resolve-package/-/resolve-package-1.0.47.tgz#8ec25f291a316280a281632331e88926a66fdf19" - integrity sha512-Zaw58gQxjQceJqhqybJi1oUDaORT8i2GTgwICPs8v/X/Pkx35FXQba69ldHVg5pQZ6YLKpROXgyHvBaCJOFXiA== - dependencies: - pkg-dir "< 6 >= 5" - tslib "^2" - upath2 "^3.1.13" - -aggregate-error@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" - integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== - dependencies: - clean-stack "^2.0.0" - indent-string "^4.0.0" - -ansi-escapes@^4.2.1: +"@typescript-eslint/eslint-plugin@8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz#515170100ff867445fe0a17ce05c14fc5fd9ca63" + integrity sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg== + dependencies: + "@eslint-community/regexpp" "^4.10.0" + "@typescript-eslint/scope-manager" "8.35.0" + "@typescript-eslint/type-utils" "8.35.0" + "@typescript-eslint/utils" "8.35.0" + "@typescript-eslint/visitor-keys" "8.35.0" + graphemer "^1.4.0" + ignore "^7.0.0" + natural-compare "^1.4.0" + ts-api-utils "^2.1.0" + +"@typescript-eslint/parser@8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.35.0.tgz#20a0e17778a329a6072722f5ac418d4376b767d2" + integrity sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA== + dependencies: + "@typescript-eslint/scope-manager" "8.35.0" + "@typescript-eslint/types" "8.35.0" + "@typescript-eslint/typescript-estree" "8.35.0" + "@typescript-eslint/visitor-keys" "8.35.0" + debug "^4.3.4" + +"@typescript-eslint/project-service@8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.35.0.tgz#00bd77e6845fbdb5684c6ab2d8a400a58dcfb07b" + integrity sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.35.0" + "@typescript-eslint/types" "^8.35.0" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz#8ccb2ab63383544fab98fc4b542d8d141259ff4f" + integrity sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA== + dependencies: + "@typescript-eslint/types" "8.35.0" + "@typescript-eslint/visitor-keys" "8.35.0" + +"@typescript-eslint/tsconfig-utils@8.35.0", "@typescript-eslint/tsconfig-utils@^8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz#6e05aeb999999e31d562ceb4fe144f3cbfbd670e" + integrity sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA== + +"@typescript-eslint/type-utils@8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz#0201eae9d83ffcc3451ef8c94f53ecfbf2319ecc" + integrity sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA== + dependencies: + "@typescript-eslint/typescript-estree" "8.35.0" + "@typescript-eslint/utils" "8.35.0" + debug "^4.3.4" + ts-api-utils "^2.1.0" + +"@typescript-eslint/types@8.35.0", "@typescript-eslint/types@^8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.35.0.tgz#e60d062907930e30008d796de5c4170f02618a93" + integrity sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ== + +"@typescript-eslint/typescript-estree@8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz#86141e6c55b75bc1eaecc0781bd39704de14e52a" + integrity sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w== + dependencies: + "@typescript-eslint/project-service" "8.35.0" + "@typescript-eslint/tsconfig-utils" "8.35.0" + "@typescript-eslint/types" "8.35.0" + "@typescript-eslint/visitor-keys" "8.35.0" + debug "^4.3.4" + fast-glob "^3.3.2" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^2.1.0" + +"@typescript-eslint/utils@8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.35.0.tgz#aaf0afab5ab51ea2f1897002907eacd9834606d5" + integrity sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg== + dependencies: + "@eslint-community/eslint-utils" "^4.7.0" + "@typescript-eslint/scope-manager" "8.35.0" + "@typescript-eslint/types" "8.35.0" + "@typescript-eslint/typescript-estree" "8.35.0" + +"@typescript-eslint/visitor-keys@8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz#93e905e7f1e94d26a79771d1b1eb0024cb159dbf" + integrity sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g== + dependencies: + "@typescript-eslint/types" "8.35.0" + eslint-visitor-keys "^4.2.1" + +"@ungap/structured-clone@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" + integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== + +"@unrs/resolver-binding-darwin-arm64@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.8.1.tgz#4ebdbe47a4d8e45690f03482d7463b282683ded8" + integrity sha512-OKuBTQdOb4Kjbe+y4KgbRhn+nu47hNyNU2K3qjD+SA/bnQouvZnRzEiR85xZAIyZ6z1C+O1Zg1dK4hGH1RPdYA== + +"@unrs/resolver-binding-darwin-x64@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.8.1.tgz#2d36bee16e8dc8594a7ddbd04cc1e6c572bb1b68" + integrity sha512-inaphBsOqqzauNvx6kSHrgqDLShicPg3+fInBcEdD7Ut8sUUbm2z19LL+S9ccGpHnYoNiJ+Qrf7/B8hRsCUvBw== + +"@unrs/resolver-binding-freebsd-x64@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.8.1.tgz#cc546963b5eabd059587498f81cf69a6f75e89ec" + integrity sha512-LkGw7jDoLKEZO6yYwTKUlrboD6Qmy9Jkq7ZDPlJReq/FnCnNh0k1Z1hjtevpqPCMLz9hGW0ITMb04jdDZ796Cg== + +"@unrs/resolver-binding-linux-arm-gnueabihf@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.8.1.tgz#7135d3df1faa37b33923c8ee20314b6cef267e7f" + integrity sha512-6vhu22scv64dynXTVmeClenn3OPI8cwdhtydLFDkoW4UJzNwcgJ5mVtzbtikDGM9PmIQa+ekpH6tdvKt0ToK3A== + +"@unrs/resolver-binding-linux-arm-musleabihf@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.8.1.tgz#0ae5128e38d8df8ad685867b4de4d8df9a4fbf35" + integrity sha512-SrQ286JVFWlnZSm1/TJwulTgJVOdb1x8BWW2ecOK0Sx+acdRpoMf4WSxH+/+R4LyE/YYyekcEtUrPhSEgJ748g== + +"@unrs/resolver-binding-linux-arm64-gnu@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.8.1.tgz#139d34a03f4232836e761f2aa28751fd8f86fa0e" + integrity sha512-I2s4L27V+2kAee43x/qAkFjTZJgmDvSd9vtnyINOdBEdz5+QqiG6ccd5pgOw06MsUwygkrhB4jOe4ZN4SA6IwA== + +"@unrs/resolver-binding-linux-arm64-musl@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.8.1.tgz#b095fcb567f00c6df88ff334e0c1cba3ea12ec44" + integrity sha512-Drq80e/EQbdSVyJpheF65qVmfYy8OaDdQqoWV+09tZHz/P1SdSulvVtgtYrk216D++9hbx3c1bwVXwR5PZ2TzA== + +"@unrs/resolver-binding-linux-ppc64-gnu@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.8.1.tgz#3fde0589f276be5e21d7b90fe264448839afa2b8" + integrity sha512-EninHQHw8Zkq8K5qB6KWNDqjCtUzTDsCRQ6LzAtQWIxic/VQxR5Kl36V/GCXNvQaR7W0AB5gvJLyQtJwkf+AJA== + +"@unrs/resolver-binding-linux-riscv64-gnu@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.8.1.tgz#ad21df9d44960e751cadf10a915eb190044be433" + integrity sha512-s7Xu5PS4vWhsb5ZFAi+UBguTn0g8qDhN+BbB1t9APX23AdAI7TS4DRrJV5dBVdQ6a8MiergGr1Cjb0Q1V/sW8w== + +"@unrs/resolver-binding-linux-riscv64-musl@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.8.1.tgz#b38d5c9fad291b542e45baccd9d43849f4dc0733" + integrity sha512-Ca+bVzOJtgQ3OrMkRSeDLYWJIjRmEylDHSZuSKqqPmZI2vgX6yZgzrKY28I6hjjG9idlW4DcJzLv/TjFXev+4Q== + +"@unrs/resolver-binding-linux-s390x-gnu@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.8.1.tgz#b77d80b7dea1e34d833b21d9579fd4956061d5c5" + integrity sha512-ut1vBBFs6AC5EcerH8HorcmS/9wAy6iI1tfpzT7jy+SKnMgmPth/psc3W5V04njble7cyLPjFHwYJTlxmozQ/g== + +"@unrs/resolver-binding-linux-x64-gnu@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.8.1.tgz#a2371b20d7ae7f482d247f2f45699ea314a4ceeb" + integrity sha512-w5agLxesvrYKrCOlAsUkwRDogjnyRBi4/vEaujZRkXbeRCupJ9dFD0qUhLXZyIed+GSzJJIsJocUZIVzcTHYXQ== + +"@unrs/resolver-binding-linux-x64-musl@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.8.1.tgz#45f2f58ad4fc103f7feea0899e638021d5bf2c57" + integrity sha512-vk5htmWYCLRpfjn2wmCUne6pLvlcYUFDAAut4g02/2iWeGeZO/3GmSLmiZ9fcn9oH0FUzgetg0/zSo8oZ7liIg== + +"@unrs/resolver-binding-wasm32-wasi@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.8.1.tgz#d57ea7275464328403fc103347db1fa4900f220e" + integrity sha512-RcsLTcrqDT5XW/TnhhIeM7lVLgUv/gvPEC4WaH+OhkLCkRfH6EEuhprwrcp1WhdlrtL/U5FkHh4NtFLnMXoeXA== + dependencies: + "@napi-rs/wasm-runtime" "^0.2.11" + +"@unrs/resolver-binding-win32-arm64-msvc@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.8.1.tgz#41eeb4731c22e74d87be0628df1eb79298ed663b" + integrity sha512-XbSRLZY/gEi5weYv/aCkiUiSWvrNKkvec3m6/bDypDI+ZACwMllPH7smeOW/fdnIGhf9YtPATNliJHAS2GyMUA== + +"@unrs/resolver-binding-win32-ia32-msvc@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.8.1.tgz#399763affd06fb409f6d22f1e4d26e9cb3ac8982" + integrity sha512-SbCJMKOmqOsIBCklT5c+t0DjVbOkseE7ZN0OtMxRnraLKdj1AAv7d3cjJMYkPd9ZGKosHoMXo66gBs02YM8KeA== + +"@unrs/resolver-binding-win32-x64-msvc@1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.8.1.tgz#91e3322e735382cbe5611d1467fd95411cb58141" + integrity sha512-DdHqo7XbeUa/ZOcxq+q5iuO4sSxhwX9HR1JPL0JMOKEzgkIO4OKF2TPjqmo6UCCGZUXIMwrAycFXj/40sICagw== + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.14.0, acorn@^8.15.0: + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +ajv@^6.12.4: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-escapes@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== @@ -787,6 +1393,11 @@ ansi-regex@^5.0.1: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== +ansi-regex@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.1.0.tgz#95ec409c69619d6cb1b8b34f14b660ef28ebd654" + integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== + ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" @@ -801,12 +1412,22 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -ansi-styles@^5.0.0: +ansi-styles@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== -anymatch@^3.0.3: +ansi-styles@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" + integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== + +ansis@^4.0.0, ansis@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ansis/-/ansis-4.1.0.tgz#cd43ecd3f814f37223e518291c0e0b04f2915a0d" + integrity sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w== + +anymatch@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== @@ -821,76 +1442,96 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" -array-union@^2.1.0: +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +ast-kit@^2.1.0: version "2.1.0" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" - integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== - -babel-jest@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" - integrity sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg== - dependencies: - "@jest/transform" "^29.7.0" - "@types/babel__core" "^7.1.14" - babel-plugin-istanbul "^6.1.1" - babel-preset-jest "^29.6.3" - chalk "^4.0.0" - graceful-fs "^4.2.9" + resolved "https://registry.yarnpkg.com/ast-kit/-/ast-kit-2.1.0.tgz#4544a2511f9300c74179ced89251bfdcb47e6d79" + integrity sha512-ROM2LlXbZBZVk97crfw8PGDOBzzsJvN2uJCmwswvPUNyfH14eg90mSN3xNqsri1JS1G9cz0VzeDUhxJkTrr4Ew== + dependencies: + "@babel/parser" "^7.27.3" + pathe "^2.0.3" + +async@^3.2.3: + version "3.2.6" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" + integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== + +babel-jest@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-30.0.2.tgz#f627dc5afc3bd5795fc84735b4f1d74f9d4b8e91" + integrity sha512-A5kqR1/EUTidM2YC2YMEUDP2+19ppgOwK0IAd9Swc3q2KqFb5f9PtRUXVeZcngu0z5mDMyZ9zH2huJZSOMLiTQ== + dependencies: + "@jest/transform" "30.0.2" + "@types/babel__core" "^7.20.5" + babel-plugin-istanbul "^7.0.0" + babel-preset-jest "30.0.1" + chalk "^4.1.2" + graceful-fs "^4.2.11" slash "^3.0.0" -babel-plugin-istanbul@^6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz#fa88ec59232fd9b4e36dbbc540a8ec9a9b47da73" - integrity sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA== +babel-plugin-istanbul@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.0.tgz#629a178f63b83dc9ecee46fd20266283b1f11280" + integrity sha512-C5OzENSx/A+gt7t4VH1I2XsflxyPUmXRFPKBxt33xncdOmq7oROVM3bZv9Ysjjkv8OJYDMa+tKuKMvqU/H3xdw== dependencies: "@babel/helper-plugin-utils" "^7.0.0" "@istanbuljs/load-nyc-config" "^1.0.0" - "@istanbuljs/schema" "^0.1.2" - istanbul-lib-instrument "^5.0.4" + "@istanbuljs/schema" "^0.1.3" + istanbul-lib-instrument "^6.0.2" test-exclude "^6.0.0" -babel-plugin-jest-hoist@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz#aadbe943464182a8922c3c927c3067ff40d24626" - integrity sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg== +babel-plugin-jest-hoist@30.0.1: + version "30.0.1" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.0.1.tgz#f271b2066d2c1fb26a863adb8e13f85b06247125" + integrity sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ== dependencies: - "@babel/template" "^7.3.3" - "@babel/types" "^7.3.3" - "@types/babel__core" "^7.1.14" - "@types/babel__traverse" "^7.0.6" + "@babel/template" "^7.27.2" + "@babel/types" "^7.27.3" + "@types/babel__core" "^7.20.5" -babel-preset-current-node-syntax@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz#b4399239b89b2a011f9ddbe3e4f401fc40cff73b" - integrity sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ== +babel-preset-current-node-syntax@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz#9a929eafece419612ef4ae4f60b1862ebad8ef30" + integrity sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw== dependencies: "@babel/plugin-syntax-async-generators" "^7.8.4" "@babel/plugin-syntax-bigint" "^7.8.3" - "@babel/plugin-syntax-class-properties" "^7.8.3" - "@babel/plugin-syntax-import-meta" "^7.8.3" + "@babel/plugin-syntax-class-properties" "^7.12.13" + "@babel/plugin-syntax-class-static-block" "^7.14.5" + "@babel/plugin-syntax-import-attributes" "^7.24.7" + "@babel/plugin-syntax-import-meta" "^7.10.4" "@babel/plugin-syntax-json-strings" "^7.8.3" - "@babel/plugin-syntax-logical-assignment-operators" "^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" - "@babel/plugin-syntax-numeric-separator" "^7.8.3" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" "@babel/plugin-syntax-object-rest-spread" "^7.8.3" "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" "@babel/plugin-syntax-optional-chaining" "^7.8.3" - "@babel/plugin-syntax-top-level-await" "^7.8.3" + "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + "@babel/plugin-syntax-top-level-await" "^7.14.5" -babel-preset-jest@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz#fa05fa510e7d493896d7b0dd2033601c840f171c" - integrity sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA== +babel-preset-jest@30.0.1: + version "30.0.1" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-30.0.1.tgz#7d28db9531bce264e846c8483d54236244b8ae88" + integrity sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw== dependencies: - babel-plugin-jest-hoist "^29.6.3" - babel-preset-current-node-syntax "^1.0.0" + babel-plugin-jest-hoist "30.0.1" + babel-preset-current-node-syntax "^1.1.0" balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +birpc@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/birpc/-/birpc-2.3.0.tgz#e5a402dc785ef952a2383ef3cfc075e0842f3e8c" + integrity sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -899,6 +1540,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + braces@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" @@ -916,7 +1564,17 @@ browserslist@^4.22.2: node-releases "^2.0.14" update-browserslist-db "^1.0.16" -bs-logger@0.x: +browserslist@^4.24.0: + version "4.25.0" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.25.0.tgz#986aa9c6d87916885da2b50d8eb577ac8d133b2c" + integrity sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA== + dependencies: + caniuse-lite "^1.0.30001718" + electron-to-chromium "^1.5.160" + node-releases "^2.0.19" + update-browserslist-db "^1.1.3" + +bs-logger@^0.2.6: version "0.2.6" resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== @@ -935,12 +1593,12 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -builtin-modules@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" - integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== +cac@^6.7.14: + version "6.7.14" + resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== -callsites@^3.0.0: +callsites@^3.0.0, callsites@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== @@ -950,7 +1608,7 @@ camelcase@^5.3.1: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.2.0: +camelcase@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== @@ -960,6 +1618,11 @@ caniuse-lite@^1.0.30001629: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001632.tgz#964207b7cba5851701afb4c8afaf1448db3884b6" integrity sha512-udx3o7yHJfUxMLkGohMlVHCvFvWmirKh9JAH/d7WOLPetlH+LTL5cocMZ0t7oZx/mdlOWXti97xLZWc8uURRHg== +caniuse-lite@^1.0.30001718: + version "1.0.30001722" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001722.tgz#ec25a2b3085b25b9079b623db83c22a70882ce85" + integrity sha512-DCQHBBZtiK6JVkAGw7drvAMK0Q0POD/xZvEmDp6baiMMP6QXXk9HpD6mNYBZWhOPG6LvIDb82ITqtWjhDckHCA== + chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -969,7 +1632,7 @@ chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^4.0.0: +chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -982,20 +1645,22 @@ char-regex@^1.0.2: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== -ci-info@^3.2.0: - version "3.9.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" - integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== +chokidar@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" -cjs-module-lexer@^1.0.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz#c485341ae8fd999ca4ee5af2d7a1c9ae01e0099c" - integrity sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q== +ci-info@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.2.0.tgz#cbd21386152ebfe1d56f280a3b5feccbd96764c7" + integrity sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg== -clean-stack@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" - integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== +cjs-module-lexer@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz#586e87d4341cb2661850ece5190232ccdebcff8b" + integrity sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA== cliui@^8.0.1: version "8.0.1" @@ -1011,7 +1676,7 @@ co@^4.6.0: resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== -collect-v8-coverage@^1.0.0: +collect-v8-coverage@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz#c0b29bcd33bcd0779a1344c2136051e6afd3d9e9" integrity sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q== @@ -1040,16 +1705,6 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -colorette@^1.1.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.4.0.tgz#5190fbb87276259a86ad700bff2c6d6faa3fca40" - integrity sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g== - -commondir@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" - integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== - concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1060,19 +1715,6 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== -create-jest@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" - integrity sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q== - dependencies: - "@jest/types" "^29.6.3" - chalk "^4.0.0" - exit "^0.1.2" - graceful-fs "^4.2.9" - jest-config "^29.7.0" - jest-util "^29.7.0" - prompts "^2.0.1" - cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -1082,12 +1724,19 @@ cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" -date-fns@^2.29.3: - version "2.30.0" - resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0" - integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw== +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: - "@babel/runtime" "^7.21.0" + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +date-fns@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14" + integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg== debug@^4.1.0, debug@^4.1.1, debug@^4.3.1: version "4.3.5" @@ -1096,52 +1745,70 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.1: dependencies: ms "2.1.2" -dedent@^1.0.0: - version "1.5.3" - resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.3.tgz#99aee19eb9bae55a67327717b6e848d0bf777e5a" - integrity sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ== +debug@^4.3.2, debug@^4.3.4, debug@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" + integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== + dependencies: + ms "^2.1.3" + +dedent@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.6.0.tgz#79d52d6389b1ffa67d2bcef59ba51847a9d503b2" + integrity sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA== -deepmerge@^4.2.2: +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +deepmerge@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== -del@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/del/-/del-5.1.0.tgz#d9487c94e367410e6eff2925ee58c0c84a75b3a7" - integrity sha512-wH9xOVHnczo9jN2IW68BabcecVPxacIA3g/7z6vhSU/4stOKQzeCRK0yD0A24WiAAUJmmVpWqrERcTxnLo3AnA== - dependencies: - globby "^10.0.1" - graceful-fs "^4.2.2" - is-glob "^4.0.1" - is-path-cwd "^2.2.0" - is-path-inside "^3.0.1" - p-map "^3.0.0" - rimraf "^3.0.0" - slash "^3.0.0" +defu@^6.1.4: + version "6.1.4" + resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.4.tgz#4e0c9cf9ff68fe5f3d7f2765cc1a012dfdcb0479" + integrity sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg== -detect-newline@^3.0.0: +detect-newline@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== -diff-sequences@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" - integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== +diff@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.2.tgz#712156a6dd288e66ebb986864e190c2fc9eddfae" + integrity sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg== -dir-glob@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" - integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== +dts-resolver@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/dts-resolver/-/dts-resolver-2.1.1.tgz#313adeda96d66492e9e24a0c5ac820ab4f11878e" + integrity sha512-3BiGFhB6mj5Kv+W2vdJseQUYW+SKVzAFJL6YNP6ursbrwy1fXHRotfHi3xLNxe4wZl/K8qbAFeCDjZLjzqxxRw== + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +ejs@^3.1.10: + version "3.1.10" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b" + integrity sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA== dependencies: - path-type "^4.0.0" + jake "^10.8.5" electron-to-chromium@^1.4.796: version "1.4.799" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.799.tgz#271c56654ab4dc703037e47a5af4fc8945160611" integrity sha512-3D3DwWkRTzrdEpntY0hMLYwj7SeBk1138CkPE8sBDSj3WzrzOiG2rHm3luw8jucpf+WiyLBCZyU9lMHyQI9M9Q== +electron-to-chromium@^1.5.160: + version "1.5.166" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.166.tgz#3fff386ed473cc2169dbe2d3ace9592262601114" + integrity sha512-QPWqHL0BglzPYyJJ1zSSmwFFL6MFXhbACOCcsCdUMCkzPdS9/OIBVxg516X/Ado2qwAq8k0nJJ7phQPCqiaFAw== + emittery@^0.13.1: version "0.13.1" resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" @@ -1152,6 +1819,16 @@ emoji-regex@^8.0.0: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +empathic@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/empathic/-/empathic-1.1.0.tgz#a0de7dcaab07695bcab54117116d44c92b89e79f" + integrity sha512-rsPft6CK3eHtrlp9Y5ALBb+hfK+DWnA4WFebbazxjWyx8vSm3rZeoM3z9irsjcqO3PYRzlfv27XIB4tz2DV7RA== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -1164,6 +1841,11 @@ escalade@^3.1.1, escalade@^3.1.2: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== +escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" @@ -1174,22 +1856,123 @@ escape-string-regexp@^2.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-scope@^8.4.0: + version "8.4.0" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.4.0.tgz#88e646a207fad61436ffa39eb505147200655c82" + integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz#687bacb2af884fcdda8a6e7d65c606f46a14cd45" + integrity sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw== + +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +eslint@^9.29.0: + version "9.29.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.29.0.tgz#65e3db3b7e5a5b04a8af541741a0f3648d0a81a6" + integrity sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.12.1" + "@eslint/config-array" "^0.20.1" + "@eslint/config-helpers" "^0.2.1" + "@eslint/core" "^0.14.0" + "@eslint/eslintrc" "^3.3.1" + "@eslint/js" "9.29.0" + "@eslint/plugin-kit" "^0.3.1" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + "@types/json-schema" "^7.0.15" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^8.4.0" + eslint-visitor-keys "^4.2.1" + espree "^10.4.0" + esquery "^1.5.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + +espree@^10.0.1: + version "10.3.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.3.0.tgz#29267cf5b0cb98735b65e64ba07e0ed49d1eed8a" + integrity sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg== + dependencies: + acorn "^8.14.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.2.0" + +espree@^10.4.0: + version "10.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837" + integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== + dependencies: + acorn "^8.15.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.2.1" + esprima@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -estree-walker@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" - integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== +esquery@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" + integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== + dependencies: + estraverse "^5.1.0" -estree-walker@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" - integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -execa@^5.0.0: +execa@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== @@ -1204,63 +1987,94 @@ execa@^5.0.0: signal-exit "^3.0.3" strip-final-newline "^2.0.0" -exit@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" - integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== - -expect@^29.0.0, expect@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" - integrity sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw== - dependencies: - "@jest/expect-utils" "^29.7.0" - jest-get-type "^29.6.3" - jest-matcher-utils "^29.7.0" - jest-message-util "^29.7.0" - jest-util "^29.7.0" - -fast-glob@3.2.12: - version "3.2.12" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" - integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w== - dependencies: - "@nodelib/fs.stat" "^2.0.2" - "@nodelib/fs.walk" "^1.2.3" - glob-parent "^5.1.2" - merge2 "^1.3.0" - micromatch "^4.0.4" +exit-x@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/exit-x/-/exit-x-0.2.2.tgz#1f9052de3b8d99a696b10dad5bced9bdd5c3aa64" + integrity sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ== + +expect@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/expect/-/expect-30.0.2.tgz#d073942c19d54cb7bc42c9b2a434d850433a7def" + integrity sha512-YN9Mgv2mtTWXVmifQq3QT+ixCL/uLuLJw+fdp8MOjKqu8K3XQh3o5aulMM1tn+O2DdrWNxLZTeJsCY/VofUA0A== + dependencies: + "@jest/expect-utils" "30.0.2" + "@jest/get-type" "30.0.1" + jest-matcher-utils "30.0.2" + jest-message-util "30.0.2" + jest-mock "30.0.2" + jest-util "30.0.2" + +expect@^30.0.0: + version "30.0.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-30.0.0.tgz#460dfda282e0a8de8302aabee951dba7e79a5a53" + integrity sha512-xCdPp6gwiR9q9lsPCHANarIkFTN/IMZso6Kkq03sOm9IIGtzK/UJqml0dkhHibGh8HKOj8BIDIpZ0BZuU7QK6w== + dependencies: + "@jest/expect-utils" "30.0.0" + "@jest/get-type" "30.0.0" + jest-matcher-utils "30.0.0" + jest-message-util "30.0.0" + jest-mock "30.0.0" + jest-util "30.0.0" + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-glob@^3.0.3: - version "3.3.2" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" - integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== +fast-glob@^3.3.2: + version "3.3.3" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== dependencies: "@nodelib/fs.stat" "^2.0.2" "@nodelib/fs.walk" "^1.2.3" glob-parent "^5.1.2" merge2 "^1.3.0" - micromatch "^4.0.4" + micromatch "^4.0.8" -fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.1.0: +fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + fastq@^1.6.0: - version "1.17.1" - resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47" - integrity sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w== + version "1.19.1" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5" + integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ== dependencies: reusify "^1.0.4" -fb-watchman@^2.0.0: +fb-watchman@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" integrity sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA== dependencies: bser "2.1.1" +fdir@^6.4.4: + version "6.4.5" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.5.tgz#328e280f3a23699362f95f2e82acf978a0c0cb49" + integrity sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw== + +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + +filelist@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" + integrity sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q== + dependencies: + minimatch "^5.0.1" + fill-range@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" @@ -1268,15 +2082,6 @@ fill-range@^7.1.1: dependencies: to-regex-range "^5.0.1" -find-cache-dir@^3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" - integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== - dependencies: - commondir "^1.0.1" - make-dir "^3.0.2" - pkg-dir "^4.1.0" - find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" @@ -1293,39 +2098,37 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" -fs-extra@^10.0.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" - integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== dependencies: - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" + flatted "^3.2.9" + keyv "^4.5.4" -fs-extra@^8.1.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" - integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== +flatted@^3.2.9: + version "3.3.3" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" + integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + +foreground-child@^3.1.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== dependencies: - graceful-fs "^4.2.0" - jsonfile "^4.0.0" - universalify "^0.1.0" + cross-spawn "^7.0.6" + signal-exit "^4.0.1" fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@^2.3.2, fsevents@~2.3.2: +fsevents@^2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== -function-bind@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" - integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== - gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -1346,6 +2149,13 @@ get-stream@^6.0.0: resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== +get-tsconfig@^4.10.1: + version "4.10.1" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.10.1.tgz#d34c1c01f47d65a606c37aa7a177bc3e56ab4b2e" + integrity sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ== + dependencies: + resolve-pkg-maps "^1.0.0" + glob-parent@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" @@ -1353,7 +2163,26 @@ glob-parent@^5.1.2: dependencies: is-glob "^4.0.1" -glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@^10.3.10: + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +glob@^7.1.4: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -1370,39 +2199,21 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -globby@10.0.1: - version "10.0.1" - resolved "https://registry.yarnpkg.com/globby/-/globby-10.0.1.tgz#4782c34cb75dd683351335c5829cc3420e606b22" - integrity sha512-sSs4inE1FB2YQiymcmTv6NWENryABjUNPeWhOvmn4SjtKybglsyPZxFB3U1/+L1bYi0rNZDqCLlHyLYDl1Pq5A== - dependencies: - "@types/glob" "^7.1.1" - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.0.3" - glob "^7.1.3" - ignore "^5.1.1" - merge2 "^1.2.3" - slash "^3.0.0" - -globby@^10.0.1: - version "10.0.2" - resolved "https://registry.yarnpkg.com/globby/-/globby-10.0.2.tgz#277593e745acaa4646c3ab411289ec47a0392543" - integrity sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg== - dependencies: - "@types/glob" "^7.1.1" - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.0.3" - glob "^7.1.3" - ignore "^5.1.1" - merge2 "^1.2.3" - slash "^3.0.0" +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== -graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.9: +graceful-fs@^4.2.11: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -1413,12 +2224,10 @@ has-flag@^4.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== -hasown@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" - integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== - dependencies: - function-bind "^1.1.2" +hookable@^5.5.3: + version "5.5.3" + resolved "https://registry.yarnpkg.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d" + integrity sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ== html-escaper@^2.0.0: version "2.0.2" @@ -1430,15 +2239,28 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== -ignore@^5.1.1: - version "5.3.1" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef" - integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw== +ignore@^5.2.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== -import-local@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.1.0.tgz#b4479df8a5fd44f6cdce24070675676063c95cb4" - integrity sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg== +ignore@^7.0.0: + version "7.0.5" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + +import-fresh@^3.2.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +import-local@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260" + integrity sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA== dependencies: pkg-dir "^4.2.0" resolve-cwd "^3.0.0" @@ -1448,11 +2270,6 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== -indent-string@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" - integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== - inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" @@ -1471,20 +2288,6 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== -is-builtin-module@^3.1.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.1.tgz#f03271717d8654cfcaf07ab0463faa3571581169" - integrity sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A== - dependencies: - builtin-modules "^3.3.0" - -is-core-module@^2.13.0: - version "2.13.1" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384" - integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw== - dependencies: - hasown "^2.0.0" - is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -1495,50 +2298,23 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== -is-generator-fn@^2.0.0: +is-generator-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== -is-glob@^4.0.1: +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== dependencies: is-extglob "^2.1.1" -is-module@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" - integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g== - is-number@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== -is-path-cwd@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-2.2.0.tgz#67d43b82664a7b5191fd9119127eb300048a9fdb" - integrity sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ== - -is-path-inside@^3.0.1: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - -is-plain-object@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-3.0.1.tgz#662d92d24c0aa4302407b0d45d21f2251c85f85b" - integrity sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g== - -is-reference@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7" - integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ== - dependencies: - "@types/estree" "*" - is-stream@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" @@ -1554,17 +2330,6 @@ istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== -istanbul-lib-instrument@^5.0.4: - version "5.2.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz#d10c8885c2125574e1c231cacadf955675e1ce3d" - integrity sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg== - dependencies: - "@babel/core" "^7.12.3" - "@babel/parser" "^7.14.7" - "@istanbuljs/schema" "^0.1.2" - istanbul-lib-coverage "^3.2.0" - semver "^6.3.0" - istanbul-lib-instrument@^6.0.0: version "6.0.2" resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.2.tgz#91655936cf7380e4e473383081e38478b69993b1" @@ -1576,6 +2341,17 @@ istanbul-lib-instrument@^6.0.0: istanbul-lib-coverage "^3.2.0" semver "^7.5.4" +istanbul-lib-instrument@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz#fa15401df6c15874bcb2105f773325d78c666765" + integrity sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q== + dependencies: + "@babel/core" "^7.23.9" + "@babel/parser" "^7.23.9" + "@istanbuljs/schema" "^0.1.3" + istanbul-lib-coverage "^3.2.0" + semver "^7.5.4" + istanbul-lib-report@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" @@ -1585,14 +2361,14 @@ istanbul-lib-report@^3.0.0: make-dir "^4.0.0" supports-color "^7.1.0" -istanbul-lib-source-maps@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" - integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== +istanbul-lib-source-maps@^5.0.0: + version "5.0.6" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz#acaef948df7747c8eb5fbf1265cb980f6353a441" + integrity sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A== dependencies: + "@jridgewell/trace-mapping" "^0.3.23" debug "^4.1.1" istanbul-lib-coverage "^3.0.0" - source-map "^0.6.1" istanbul-reports@^3.1.3: version "3.1.7" @@ -1602,363 +2378,446 @@ istanbul-reports@^3.1.3: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" -jest-changed-files@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.7.0.tgz#1c06d07e77c78e1585d020424dedc10d6e17ac3a" - integrity sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w== +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +jake@^10.8.5: + version "10.9.2" + resolved "https://registry.yarnpkg.com/jake/-/jake-10.9.2.tgz#6ae487e6a69afec3a5e167628996b59f35ae2b7f" + integrity sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA== dependencies: - execa "^5.0.0" - jest-util "^29.7.0" + async "^3.2.3" + chalk "^4.0.2" + filelist "^1.0.4" + minimatch "^3.1.2" + +jest-changed-files@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-30.0.2.tgz#2c275263037f8f291b71cbb0a4f639c519ab7eb8" + integrity sha512-Ius/iRST9FKfJI+I+kpiDh8JuUlAISnRszF9ixZDIqJF17FckH5sOzKC8a0wd0+D+8em5ADRHA5V5MnfeDk2WA== + dependencies: + execa "^5.1.1" + jest-util "30.0.2" p-limit "^3.1.0" -jest-circus@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-29.7.0.tgz#b6817a45fcc835d8b16d5962d0c026473ee3668a" - integrity sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw== +jest-circus@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-30.0.2.tgz#a00a408d5d32d2b547f20f9e84a487d236ed8ee1" + integrity sha512-NRozwx4DaFHcCUtwdEd/0jBLL1imyMrCbla3vF//wdsB2g6jIicMbjx9VhqE/BYU4dwsOQld+06ODX0oZ9xOLg== dependencies: - "@jest/environment" "^29.7.0" - "@jest/expect" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/types" "^29.6.3" + "@jest/environment" "30.0.2" + "@jest/expect" "30.0.2" + "@jest/test-result" "30.0.2" + "@jest/types" "30.0.1" "@types/node" "*" - chalk "^4.0.0" + chalk "^4.1.2" co "^4.6.0" - dedent "^1.0.0" - is-generator-fn "^2.0.0" - jest-each "^29.7.0" - jest-matcher-utils "^29.7.0" - jest-message-util "^29.7.0" - jest-runtime "^29.7.0" - jest-snapshot "^29.7.0" - jest-util "^29.7.0" + dedent "^1.6.0" + is-generator-fn "^2.1.0" + jest-each "30.0.2" + jest-matcher-utils "30.0.2" + jest-message-util "30.0.2" + jest-runtime "30.0.2" + jest-snapshot "30.0.2" + jest-util "30.0.2" p-limit "^3.1.0" - pretty-format "^29.7.0" - pure-rand "^6.0.0" + pretty-format "30.0.2" + pure-rand "^7.0.0" slash "^3.0.0" - stack-utils "^2.0.3" - -jest-cli@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-29.7.0.tgz#5592c940798e0cae677eec169264f2d839a37995" - integrity sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg== - dependencies: - "@jest/core" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/types" "^29.6.3" - chalk "^4.0.0" - create-jest "^29.7.0" - exit "^0.1.2" - import-local "^3.0.2" - jest-config "^29.7.0" - jest-util "^29.7.0" - jest-validate "^29.7.0" - yargs "^17.3.1" - -jest-config@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-29.7.0.tgz#bcbda8806dbcc01b1e316a46bb74085a84b0245f" - integrity sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ== - dependencies: - "@babel/core" "^7.11.6" - "@jest/test-sequencer" "^29.7.0" - "@jest/types" "^29.6.3" - babel-jest "^29.7.0" - chalk "^4.0.0" - ci-info "^3.2.0" - deepmerge "^4.2.2" - glob "^7.1.3" - graceful-fs "^4.2.9" - jest-circus "^29.7.0" - jest-environment-node "^29.7.0" - jest-get-type "^29.6.3" - jest-regex-util "^29.6.3" - jest-resolve "^29.7.0" - jest-runner "^29.7.0" - jest-util "^29.7.0" - jest-validate "^29.7.0" - micromatch "^4.0.4" + stack-utils "^2.0.6" + +jest-cli@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-30.0.2.tgz#cf8ad8a1157721c3a1dc3a371565f6b7f5e6b549" + integrity sha512-yQ6Qz747oUbMYLNAqOlEby+hwXx7WEJtCl0iolBRpJhr2uvkBgiVMrvuKirBc8utwQBnkETFlDUkYifbRpmBrQ== + dependencies: + "@jest/core" "30.0.2" + "@jest/test-result" "30.0.2" + "@jest/types" "30.0.1" + chalk "^4.1.2" + exit-x "^0.2.2" + import-local "^3.2.0" + jest-config "30.0.2" + jest-util "30.0.2" + jest-validate "30.0.2" + yargs "^17.7.2" + +jest-config@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-30.0.2.tgz#a4884ba3b4d31fb0599b0b78e7a0204efb126f9d" + integrity sha512-vo0fVq+uzDcXETFVnCUyr5HaUCM8ES6DEuS9AFpma34BVXMRRNlsqDyiW5RDHaEFoeFlJHoI4Xjh/WSYIAL58g== + dependencies: + "@babel/core" "^7.27.4" + "@jest/get-type" "30.0.1" + "@jest/pattern" "30.0.1" + "@jest/test-sequencer" "30.0.2" + "@jest/types" "30.0.1" + babel-jest "30.0.2" + chalk "^4.1.2" + ci-info "^4.2.0" + deepmerge "^4.3.1" + glob "^10.3.10" + graceful-fs "^4.2.11" + jest-circus "30.0.2" + jest-docblock "30.0.1" + jest-environment-node "30.0.2" + jest-regex-util "30.0.1" + jest-resolve "30.0.2" + jest-runner "30.0.2" + jest-util "30.0.2" + jest-validate "30.0.2" + micromatch "^4.0.8" parse-json "^5.2.0" - pretty-format "^29.7.0" + pretty-format "30.0.2" slash "^3.0.0" strip-json-comments "^3.1.1" -jest-diff@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" - integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== - dependencies: - chalk "^4.0.0" - diff-sequences "^29.6.3" - jest-get-type "^29.6.3" - pretty-format "^29.7.0" - -jest-docblock@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-29.7.0.tgz#8fddb6adc3cdc955c93e2a87f61cfd350d5d119a" - integrity sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g== - dependencies: - detect-newline "^3.0.0" - -jest-each@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-29.7.0.tgz#162a9b3f2328bdd991beaabffbb74745e56577d1" - integrity sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ== - dependencies: - "@jest/types" "^29.6.3" - chalk "^4.0.0" - jest-get-type "^29.6.3" - jest-util "^29.7.0" - pretty-format "^29.7.0" - -jest-environment-node@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz#0b93e111dda8ec120bc8300e6d1fb9576e164376" - integrity sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw== - dependencies: - "@jest/environment" "^29.7.0" - "@jest/fake-timers" "^29.7.0" - "@jest/types" "^29.6.3" +jest-diff@30.0.0: + version "30.0.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-30.0.0.tgz#d3d4f75e257e3c2cb8729438fe9cec66098f6176" + integrity sha512-TgT1+KipV8JTLXXeFX0qSvIJR/UXiNNojjxb/awh3vYlBZyChU/NEmyKmq+wijKjWEztyrGJFL790nqMqNjTHA== + dependencies: + "@jest/diff-sequences" "30.0.0" + "@jest/get-type" "30.0.0" + chalk "^4.1.2" + pretty-format "30.0.0" + +jest-diff@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-30.0.2.tgz#db77e7ca48a964337c0a4259d5e389c0bb124d7e" + integrity sha512-2UjrNvDJDn/oHFpPrUTVmvYYDNeNtw2DlY3er8bI6vJJb9Fb35ycp/jFLd5RdV59tJ8ekVXX3o/nwPcscgXZJQ== + dependencies: + "@jest/diff-sequences" "30.0.1" + "@jest/get-type" "30.0.1" + chalk "^4.1.2" + pretty-format "30.0.2" + +jest-docblock@30.0.1: + version "30.0.1" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-30.0.1.tgz#545ff59f2fa88996bd470dba7d3798a8421180b1" + integrity sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA== + dependencies: + detect-newline "^3.1.0" + +jest-each@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-30.0.2.tgz#402e189784715f5c76f1bb97c29842e79abe99a1" + integrity sha512-ZFRsTpe5FUWFQ9cWTMguCaiA6kkW5whccPy9JjD1ezxh+mJeqmz8naL8Fl/oSbNJv3rgB0x87WBIkA5CObIUZQ== + dependencies: + "@jest/get-type" "30.0.1" + "@jest/types" "30.0.1" + chalk "^4.1.2" + jest-util "30.0.2" + pretty-format "30.0.2" + +jest-environment-node@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-30.0.2.tgz#3c24d6becb505f344f52cddb15ea506cf3288543" + integrity sha512-XsGtZ0H+a70RsxAQkKuIh0D3ZlASXdZdhpOSBq9WRPq6lhe0IoQHGW0w9ZUaPiZQ/CpkIdprvlfV1QcXcvIQLQ== + dependencies: + "@jest/environment" "30.0.2" + "@jest/fake-timers" "30.0.2" + "@jest/types" "30.0.1" "@types/node" "*" - jest-mock "^29.7.0" - jest-util "^29.7.0" - -jest-get-type@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" - integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== + jest-mock "30.0.2" + jest-util "30.0.2" + jest-validate "30.0.2" -jest-haste-map@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.7.0.tgz#3c2396524482f5a0506376e6c858c3bbcc17b104" - integrity sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA== +jest-haste-map@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-30.0.2.tgz#83826e7e352fa139dc95100337aff4de58c99453" + integrity sha512-telJBKpNLeCb4MaX+I5k496556Y2FiKR/QLZc0+MGBYl4k3OO0472drlV2LUe7c1Glng5HuAu+5GLYp//GpdOQ== dependencies: - "@jest/types" "^29.6.3" - "@types/graceful-fs" "^4.1.3" + "@jest/types" "30.0.1" "@types/node" "*" - anymatch "^3.0.3" - fb-watchman "^2.0.0" - graceful-fs "^4.2.9" - jest-regex-util "^29.6.3" - jest-util "^29.7.0" - jest-worker "^29.7.0" - micromatch "^4.0.4" + anymatch "^3.1.3" + fb-watchman "^2.0.2" + graceful-fs "^4.2.11" + jest-regex-util "30.0.1" + jest-util "30.0.2" + jest-worker "30.0.2" + micromatch "^4.0.8" walker "^1.0.8" optionalDependencies: - fsevents "^2.3.2" - -jest-leak-detector@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz#5b7ec0dadfdfec0ca383dc9aa016d36b5ea4c728" - integrity sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw== - dependencies: - jest-get-type "^29.6.3" - pretty-format "^29.7.0" + fsevents "^2.3.3" + +jest-leak-detector@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-30.0.2.tgz#da4df660615d170136d2b468af3bf1c9bff0137e" + integrity sha512-U66sRrAYdALq+2qtKffBLDWsQ/XoNNs2Lcr83sc9lvE/hEpNafJlq2lXCPUBMNqamMECNxSIekLfe69qg4KMIQ== + dependencies: + "@jest/get-type" "30.0.1" + pretty-format "30.0.2" + +jest-matcher-utils@30.0.0: + version "30.0.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-30.0.0.tgz#f72a65e248c0462795f7e14386682bfee6ad4386" + integrity sha512-m5mrunqopkrqwG1mMdJxe1J4uGmS9AHHKYUmoxeQOxBcLjEvirIrIDwuKmUYrecPHVB/PUBpXs2gPoeA2FSSLQ== + dependencies: + "@jest/get-type" "30.0.0" + chalk "^4.1.2" + jest-diff "30.0.0" + pretty-format "30.0.0" + +jest-matcher-utils@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-30.0.2.tgz#2dbb5f9aacfdd9c013fa72ed6132ca4e1b41f8db" + integrity sha512-1FKwgJYECR8IT93KMKmjKHSLyru0DqguThov/aWpFccC0wbiXGOxYEu7SScderBD7ruDOpl7lc5NG6w3oxKfaA== + dependencies: + "@jest/get-type" "30.0.1" + chalk "^4.1.2" + jest-diff "30.0.2" + pretty-format "30.0.2" + +jest-message-util@30.0.0: + version "30.0.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.0.0.tgz#b115d408cd877a6e3e711485a3bd240c7a27503c" + integrity sha512-pV3qcrb4utEsa/U7UI2VayNzSDQcmCllBZLSoIucrESRu0geKThFZOjjh0kACDJFJRAQwsK7GVsmS6SpEceD8w== + dependencies: + "@babel/code-frame" "^7.27.1" + "@jest/types" "30.0.0" + "@types/stack-utils" "^2.0.3" + chalk "^4.1.2" + graceful-fs "^4.2.11" + micromatch "^4.0.8" + pretty-format "30.0.0" + slash "^3.0.0" + stack-utils "^2.0.6" + +jest-message-util@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.0.2.tgz#9dfdc37570d172f0ffdc42a0318036ff4008837f" + integrity sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@jest/types" "30.0.1" + "@types/stack-utils" "^2.0.3" + chalk "^4.1.2" + graceful-fs "^4.2.11" + micromatch "^4.0.8" + pretty-format "30.0.2" + slash "^3.0.0" + stack-utils "^2.0.6" -jest-matcher-utils@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" - integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== +jest-mock@30.0.0: + version "30.0.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-30.0.0.tgz#f3b3115cd80c3eec7df93809430ab1feaeeb7229" + integrity sha512-W2sRA4ALXILrEetEOh2ooZG6fZ01iwVs0OWMKSSWRcUlaLr4ESHuiKXDNTg+ZVgOq8Ei5445i/Yxrv59VT+XkA== dependencies: - chalk "^4.0.0" - jest-diff "^29.7.0" - jest-get-type "^29.6.3" - pretty-format "^29.7.0" - -jest-message-util@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.7.0.tgz#8bc392e204e95dfe7564abbe72a404e28e51f7f3" - integrity sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w== - dependencies: - "@babel/code-frame" "^7.12.13" - "@jest/types" "^29.6.3" - "@types/stack-utils" "^2.0.0" - chalk "^4.0.0" - graceful-fs "^4.2.9" - micromatch "^4.0.4" - pretty-format "^29.7.0" - slash "^3.0.0" - stack-utils "^2.0.3" + "@jest/types" "30.0.0" + "@types/node" "*" + jest-util "30.0.0" -jest-mock@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.7.0.tgz#4e836cf60e99c6fcfabe9f99d017f3fdd50a6347" - integrity sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw== +jest-mock@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-30.0.2.tgz#5e4245f25f6f9532714906cab10a2b9e39eb2183" + integrity sha512-PnZOHmqup/9cT/y+pXIVbbi8ID6U1XHRmbvR7MvUy4SLqhCbwpkmXhLbsWbGewHrV5x/1bF7YDjs+x24/QSvFA== dependencies: - "@jest/types" "^29.6.3" + "@jest/types" "30.0.1" "@types/node" "*" - jest-util "^29.7.0" + jest-util "30.0.2" -jest-pnp-resolver@^1.2.2: +jest-pnp-resolver@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== -jest-regex-util@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.6.3.tgz#4a556d9c776af68e1c5f48194f4d0327d24e8a52" - integrity sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg== - -jest-resolve-dependencies@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz#1b04f2c095f37fc776ff40803dc92921b1e88428" - integrity sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA== - dependencies: - jest-regex-util "^29.6.3" - jest-snapshot "^29.7.0" - -jest-resolve@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-29.7.0.tgz#64d6a8992dd26f635ab0c01e5eef4399c6bcbc30" - integrity sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA== - dependencies: - chalk "^4.0.0" - graceful-fs "^4.2.9" - jest-haste-map "^29.7.0" - jest-pnp-resolver "^1.2.2" - jest-util "^29.7.0" - jest-validate "^29.7.0" - resolve "^1.20.0" - resolve.exports "^2.0.0" +jest-regex-util@30.0.0: + version "30.0.0" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.0.0.tgz#031f385ebb947e770e409ede703d200b3405413e" + integrity sha512-rT84010qRu/5OOU7a9TeidC2Tp3Qgt9Sty4pOZ/VSDuEmRupIjKZAb53gU3jr4ooMlhwScrgC9UixJxWzVu9oQ== + +jest-regex-util@30.0.1: + version "30.0.1" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.0.1.tgz#f17c1de3958b67dfe485354f5a10093298f2a49b" + integrity sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA== + +jest-resolve-dependencies@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-30.0.2.tgz#0c5da8dc5f791f3de10c1d5df294503cd612e5a6" + integrity sha512-Lp1iIXpsF5fGM4vyP8xHiIy2H5L5yO67/nXoYJzH4kz+fQmO+ZMKxzYLyWxYy4EeCLeNQ6a9OozL+uHZV2iuEA== + dependencies: + jest-regex-util "30.0.1" + jest-snapshot "30.0.2" + +jest-resolve@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-30.0.2.tgz#4b7c826a35e9657189568e4dafc0ba5f05868cf2" + integrity sha512-q/XT0XQvRemykZsvRopbG6FQUT6/ra+XV6rPijyjT6D0msOyCvR2A5PlWZLd+fH0U8XWKZfDiAgrUNDNX2BkCw== + dependencies: + chalk "^4.1.2" + graceful-fs "^4.2.11" + jest-haste-map "30.0.2" + jest-pnp-resolver "^1.2.3" + jest-util "30.0.2" + jest-validate "30.0.2" slash "^3.0.0" - -jest-runner@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-29.7.0.tgz#809af072d408a53dcfd2e849a4c976d3132f718e" - integrity sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ== - dependencies: - "@jest/console" "^29.7.0" - "@jest/environment" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" + unrs-resolver "^1.7.11" + +jest-runner@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-30.0.2.tgz#28022ea290e2759864ae97cb5307bcae98e68f2d" + integrity sha512-6H+CIFiDLVt1Ix6jLzASXz3IoIiDukpEIxL9FHtDQ2BD/k5eFtDF5e5N9uItzRE3V1kp7VoSRyrGBytXKra4xA== + dependencies: + "@jest/console" "30.0.2" + "@jest/environment" "30.0.2" + "@jest/test-result" "30.0.2" + "@jest/transform" "30.0.2" + "@jest/types" "30.0.1" "@types/node" "*" - chalk "^4.0.0" + chalk "^4.1.2" emittery "^0.13.1" - graceful-fs "^4.2.9" - jest-docblock "^29.7.0" - jest-environment-node "^29.7.0" - jest-haste-map "^29.7.0" - jest-leak-detector "^29.7.0" - jest-message-util "^29.7.0" - jest-resolve "^29.7.0" - jest-runtime "^29.7.0" - jest-util "^29.7.0" - jest-watcher "^29.7.0" - jest-worker "^29.7.0" + exit-x "^0.2.2" + graceful-fs "^4.2.11" + jest-docblock "30.0.1" + jest-environment-node "30.0.2" + jest-haste-map "30.0.2" + jest-leak-detector "30.0.2" + jest-message-util "30.0.2" + jest-resolve "30.0.2" + jest-runtime "30.0.2" + jest-util "30.0.2" + jest-watcher "30.0.2" + jest-worker "30.0.2" p-limit "^3.1.0" source-map-support "0.5.13" -jest-runtime@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-29.7.0.tgz#efecb3141cf7d3767a3a0cc8f7c9990587d3d817" - integrity sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ== - dependencies: - "@jest/environment" "^29.7.0" - "@jest/fake-timers" "^29.7.0" - "@jest/globals" "^29.7.0" - "@jest/source-map" "^29.6.3" - "@jest/test-result" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" +jest-runtime@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-30.0.2.tgz#db5b4723ebdb8c2158779c055976cb6cc22ce1df" + integrity sha512-H1a51/soNOeAjoggu6PZKTH7DFt8JEGN4mesTSwyqD2jU9PXD04Bp6DKbt2YVtQvh2JcvH2vjbkEerCZ3lRn7A== + dependencies: + "@jest/environment" "30.0.2" + "@jest/fake-timers" "30.0.2" + "@jest/globals" "30.0.2" + "@jest/source-map" "30.0.1" + "@jest/test-result" "30.0.2" + "@jest/transform" "30.0.2" + "@jest/types" "30.0.1" "@types/node" "*" - chalk "^4.0.0" - cjs-module-lexer "^1.0.0" - collect-v8-coverage "^1.0.0" - glob "^7.1.3" - graceful-fs "^4.2.9" - jest-haste-map "^29.7.0" - jest-message-util "^29.7.0" - jest-mock "^29.7.0" - jest-regex-util "^29.6.3" - jest-resolve "^29.7.0" - jest-snapshot "^29.7.0" - jest-util "^29.7.0" + chalk "^4.1.2" + cjs-module-lexer "^2.1.0" + collect-v8-coverage "^1.0.2" + glob "^10.3.10" + graceful-fs "^4.2.11" + jest-haste-map "30.0.2" + jest-message-util "30.0.2" + jest-mock "30.0.2" + jest-regex-util "30.0.1" + jest-resolve "30.0.2" + jest-snapshot "30.0.2" + jest-util "30.0.2" slash "^3.0.0" strip-bom "^4.0.0" -jest-snapshot@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-29.7.0.tgz#c2c574c3f51865da1bb329036778a69bf88a6be5" - integrity sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw== - dependencies: - "@babel/core" "^7.11.6" - "@babel/generator" "^7.7.2" - "@babel/plugin-syntax-jsx" "^7.7.2" - "@babel/plugin-syntax-typescript" "^7.7.2" - "@babel/types" "^7.3.3" - "@jest/expect-utils" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" - babel-preset-current-node-syntax "^1.0.0" - chalk "^4.0.0" - expect "^29.7.0" - graceful-fs "^4.2.9" - jest-diff "^29.7.0" - jest-get-type "^29.6.3" - jest-matcher-utils "^29.7.0" - jest-message-util "^29.7.0" - jest-util "^29.7.0" - natural-compare "^1.4.0" - pretty-format "^29.7.0" - semver "^7.5.3" - -jest-util@^29.0.0, jest-util@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc" - integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== - dependencies: - "@jest/types" "^29.6.3" +jest-snapshot@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-30.0.2.tgz#0f9f2c59c2070874a2db96d30c8543dfef657701" + integrity sha512-KeoHikoKGln3OlN7NS7raJ244nIVr2K46fBTNdfuxqYv2/g4TVyWDSO4fmk08YBJQMjs3HNfG1rlLfL/KA+nUw== + dependencies: + "@babel/core" "^7.27.4" + "@babel/generator" "^7.27.5" + "@babel/plugin-syntax-jsx" "^7.27.1" + "@babel/plugin-syntax-typescript" "^7.27.1" + "@babel/types" "^7.27.3" + "@jest/expect-utils" "30.0.2" + "@jest/get-type" "30.0.1" + "@jest/snapshot-utils" "30.0.1" + "@jest/transform" "30.0.2" + "@jest/types" "30.0.1" + babel-preset-current-node-syntax "^1.1.0" + chalk "^4.1.2" + expect "30.0.2" + graceful-fs "^4.2.11" + jest-diff "30.0.2" + jest-matcher-utils "30.0.2" + jest-message-util "30.0.2" + jest-util "30.0.2" + pretty-format "30.0.2" + semver "^7.7.2" + synckit "^0.11.8" + +jest-util@30.0.0: + version "30.0.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-30.0.0.tgz#d4f20f59e1fd72c7143143f4aa961bb71aeddad0" + integrity sha512-fhNBBM9uSUbd4Lzsf8l/kcAdaHD/4SgoI48en3HXcBEMwKwoleKFMZ6cYEYs21SB779PRuRCyNLmymApAm8tZw== + dependencies: + "@jest/types" "30.0.0" "@types/node" "*" - chalk "^4.0.0" - ci-info "^3.2.0" - graceful-fs "^4.2.9" - picomatch "^2.2.3" + chalk "^4.1.2" + ci-info "^4.2.0" + graceful-fs "^4.2.11" + picomatch "^4.0.2" -jest-validate@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.7.0.tgz#7bf705511c64da591d46b15fce41400d52147d9c" - integrity sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw== +jest-util@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-30.0.2.tgz#1bd8411f81e6f5e2ca8b31bb2534ebcd7cbac065" + integrity sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg== dependencies: - "@jest/types" "^29.6.3" - camelcase "^6.2.0" - chalk "^4.0.0" - jest-get-type "^29.6.3" + "@jest/types" "30.0.1" + "@types/node" "*" + chalk "^4.1.2" + ci-info "^4.2.0" + graceful-fs "^4.2.11" + picomatch "^4.0.2" + +jest-validate@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-30.0.2.tgz#f62a2f0e014dac94747509ba8c2bcd5d48215b7f" + integrity sha512-noOvul+SFER4RIvNAwGn6nmV2fXqBq67j+hKGHKGFCmK4ks/Iy1FSrqQNBLGKlu4ZZIRL6Kg1U72N1nxuRCrGQ== + dependencies: + "@jest/get-type" "30.0.1" + "@jest/types" "30.0.1" + camelcase "^6.3.0" + chalk "^4.1.2" leven "^3.1.0" - pretty-format "^29.7.0" + pretty-format "30.0.2" -jest-watcher@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-29.7.0.tgz#7810d30d619c3a62093223ce6bb359ca1b28a2f2" - integrity sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g== +jest-watcher@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-30.0.2.tgz#ec93ed25183679f549a47f6197267d50ec83ea51" + integrity sha512-vYO5+E7jJuF+XmONr6CrbXdlYrgvZqtkn6pdkgjt/dU64UAdc0v1cAVaAeWtAfUUMScxNmnUjKPUMdCpNVASwg== dependencies: - "@jest/test-result" "^29.7.0" - "@jest/types" "^29.6.3" + "@jest/test-result" "30.0.2" + "@jest/types" "30.0.1" "@types/node" "*" - ansi-escapes "^4.2.1" - chalk "^4.0.0" + ansi-escapes "^4.3.2" + chalk "^4.1.2" emittery "^0.13.1" - jest-util "^29.7.0" - string-length "^4.0.1" + jest-util "30.0.2" + string-length "^4.0.2" -jest-worker@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.7.0.tgz#acad073acbbaeb7262bd5389e1bcf43e10058d4a" - integrity sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw== +jest-worker@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-30.0.2.tgz#e67bd7debbc9d8445907a17067a89359acedc8c5" + integrity sha512-RN1eQmx7qSLFA+o9pfJKlqViwL5wt+OL3Vff/A+/cPsmuw7NPwfgl33AP+/agRmHzPOFgXviRycR9kYwlcRQXg== dependencies: "@types/node" "*" - jest-util "^29.7.0" + "@ungap/structured-clone" "^1.3.0" + jest-util "30.0.2" merge-stream "^2.0.0" - supports-color "^8.0.0" + supports-color "^8.1.1" -jest@^29.2.1: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest/-/jest-29.7.0.tgz#994676fc24177f088f1c5e3737f5697204ff2613" - integrity sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw== +jest@^30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest/-/jest-30.0.2.tgz#0b3af654548d706bdde6f1bba93099ec343b8772" + integrity sha512-HlSEiHRcmTuGwNyeawLTEzpQUMFn+f741FfoNg7RXG2h0WLJKozVCpcQLT0GW17H6kNCqRwGf+Ii/I1YVNvEGQ== dependencies: - "@jest/core" "^29.7.0" - "@jest/types" "^29.6.3" - import-local "^3.0.2" - jest-cli "^29.7.0" + "@jest/core" "30.0.2" + "@jest/types" "30.0.1" + import-local "^3.2.0" + jest-cli "30.0.2" + +jiti@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.4.2.tgz#d19b7732ebb6116b06e2038da74a55366faef560" + integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A== js-tokens@^4.0.0: version "4.0.0" @@ -1973,47 +2832,68 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== +jsesc@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + json-parse-even-better-errors@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + json5@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== -jsonfile@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" - integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg== - optionalDependencies: - graceful-fs "^4.1.6" - -jsonfile@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" - integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== dependencies: - universalify "^2.0.0" - optionalDependencies: - graceful-fs "^4.1.6" - -kleur@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" - integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + json-buffer "3.0.1" leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" @@ -2033,11 +2913,21 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash.memoize@4.x: +lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag== +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -2045,20 +2935,6 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" -magic-string@^0.25.7: - version "0.25.9" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c" - integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ== - dependencies: - sourcemap-codec "^1.4.8" - -make-dir@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" - integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== - dependencies: - semver "^6.0.0" - make-dir@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" @@ -2066,7 +2942,7 @@ make-dir@^4.0.0: dependencies: semver "^7.5.3" -make-error@1.x: +make-error@^1.3.6: version "1.3.6" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== @@ -2083,15 +2959,15 @@ merge-stream@^2.0.0: resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== -merge2@^1.2.3, merge2@^1.3.0: +merge2@^1.3.0: version "1.4.1" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -micromatch@^4.0.4: - version "4.0.7" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.7.tgz#33e8190d9fe474a9895525f5618eee136d46c2e5" - integrity sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q== +micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== dependencies: braces "^3.0.3" picomatch "^2.3.1" @@ -2101,18 +2977,47 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -minimatch@^3.0.4, minimatch@^3.1.1: +minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== dependencies: brace-expansion "^1.1.7" +minimatch@^5.0.1: + version "5.1.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" + integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +napi-postinstall@^0.2.2: + version "0.2.4" + resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.2.4.tgz#419697d0288cb524623e422f919624f22a5e4028" + integrity sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -2128,6 +3033,11 @@ node-releases@^2.0.14: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== +node-releases@^2.0.19: + version "2.0.19" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" + integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== + normalize-path@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" @@ -2154,6 +3064,18 @@ onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" @@ -2182,18 +3104,23 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" -p-map@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-3.0.0.tgz#d704d9af8a2ba684e2600d9a215983d4141a979d" - integrity sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ== - dependencies: - aggregate-error "^3.0.0" - p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + parse-json@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" @@ -2214,100 +3141,108 @@ path-is-absolute@^1.0.0: resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== -path-is-network-drive@^1.0.20: - version "1.0.20" - resolved "https://registry.yarnpkg.com/path-is-network-drive/-/path-is-network-drive-1.0.20.tgz#9c264db2e0fce5e9bc2ef9177fcab3f996d1a1b5" - integrity sha512-p5wCWlRB4+ggzxWshqHH9aF3kAuVu295NaENXmVhThbZPJQBeJdxZTP6CIoUR+kWHDUW56S9YcaO1gXnc/BOxw== - dependencies: - tslib "^2" - path-key@^3.0.0, path-key@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== -path-parse@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" - integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== - -path-strip-sep@^1.0.17: - version "1.0.17" - resolved "https://registry.yarnpkg.com/path-strip-sep/-/path-strip-sep-1.0.17.tgz#3b7dd4f461cf73a9277333f50289ce9b00cffba3" - integrity sha512-+2zIC2fNgdilgV7pTrktY6oOxxZUo9x5zJYfTzxsGze5kSGDDwhA5/0WlBn+sUyv/WuuyYn3OfM+Ue5nhdQUgA== +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== dependencies: - tslib "^2" + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" -path-type@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" - integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== picocolors@^1.0.0, picocolors@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.1.tgz#a8ad579b571952f0e5d25892de5445bcfe25aaa1" integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew== -picomatch@^2.0.4, picomatch@^2.2.2, picomatch@^2.2.3, picomatch@^2.3.1: +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.0.4, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -pirates@^4.0.4: - version "4.0.6" - resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" - integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== +picomatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" + integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== -"pkg-dir@< 6 >= 5": - version "5.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-5.0.0.tgz#a02d6aebe6ba133a928f74aec20bafdfe6b8e760" - integrity sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA== - dependencies: - find-up "^5.0.0" +pirates@^4.0.7: + version "4.0.7" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" + integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== -pkg-dir@^4.1.0, pkg-dir@^4.2.0: +pkg-dir@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== dependencies: find-up "^4.0.0" -pretty-format@^29.0.0, pretty-format@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" - integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +pretty-format@30.0.0, pretty-format@^30.0.0: + version "30.0.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-30.0.0.tgz#a3137bed442af87eadea2c427a1b201189e590a4" + integrity sha512-18NAOUr4ZOQiIR+BgI5NhQE7uREdx4ZyV0dyay5izh4yfQ+1T7BSvggxvRGoXocrRyevqW5OhScUjbi9GB8R8Q== dependencies: - "@jest/schemas" "^29.6.3" - ansi-styles "^5.0.0" - react-is "^18.0.0" + "@jest/schemas" "30.0.0" + ansi-styles "^5.2.0" + react-is "^18.3.1" -prompts@^2.0.1: - version "2.4.2" - resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" - integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== +pretty-format@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-30.0.2.tgz#54717b6aa2b4357a2e6d83868e10a2ea8dd647c7" + integrity sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg== dependencies: - kleur "^3.0.3" - sisteransi "^1.0.5" + "@jest/schemas" "30.0.1" + ansi-styles "^5.2.0" + react-is "^18.3.1" -pure-rand@^6.0.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" - integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +pure-rand@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-7.0.1.tgz#6f53a5a9e3e4a47445822af96821ca509ed37566" + integrity sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ== + +quansync@^0.2.10, quansync@^0.2.8: + version "0.2.10" + resolved "https://registry.yarnpkg.com/quansync/-/quansync-0.2.10.tgz#32053cf166fa36511aae95fc49796116f2dc20e1" + integrity sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A== queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -react-is@^18.0.0: +react-is@^18.3.1: version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== -regenerator-runtime@^0.14.0: - version "0.14.1" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" - integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== require-directory@^2.1.1: version "2.1.1" @@ -2321,85 +3256,62 @@ resolve-cwd@^3.0.0: dependencies: resolve-from "^5.0.0" +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + resolve-from@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== -resolve.exports@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.2.tgz#f8c934b8e6a13f539e38b7098e2e36134f01e800" - integrity sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg== - -resolve@^1.17.0, resolve@^1.19.0, resolve@^1.20.0: - version "1.22.8" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" - integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== - dependencies: - is-core-module "^2.13.0" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" +resolve-pkg-maps@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" + integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== reusify@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" - integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== - -rimraf@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - -rollup-plugin-copy@^3.4.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/rollup-plugin-copy/-/rollup-plugin-copy-3.5.0.tgz#7ffa2a7a8303e143876fa64fb5eed9022d304eeb" - integrity sha512-wI8D5dvYovRMx/YYKtUNt3Yxaw4ORC9xo6Gt9t22kveWz1enG9QrhVlagzwrxSC455xD1dHMKhIJkbsQ7d48BA== - dependencies: - "@types/fs-extra" "^8.0.1" - colorette "^1.1.0" - fs-extra "^8.1.0" - globby "10.0.1" - is-plain-object "^3.0.0" - -rollup-plugin-delete@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/rollup-plugin-delete/-/rollup-plugin-delete-2.0.0.tgz#262acf80660d48c3b167fb0baabd0c3ab985c153" - integrity sha512-/VpLMtDy+8wwRlDANuYmDa9ss/knGsAgrDhM+tEwB1npHwNu4DYNmDfUL55csse/GHs9Q+SMT/rw9uiaZ3pnzA== - dependencies: - del "^5.1.0" - -rollup-plugin-multi-input@^1.3.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/rollup-plugin-multi-input/-/rollup-plugin-multi-input-1.4.1.tgz#624792c53297965203ef40e652013d34a7eda8e9" - integrity sha512-ybvotObZFFDEbqw6MDrYUa/TXmF+1qCVX3svpAddmIOLP3/to5zkSKP0MJV5bNBZfFFpblwChurz4tsPR/zJew== - dependencies: - fast-glob "3.2.12" - -rollup-plugin-peer-deps-external@^2.2.4: - version "2.2.4" - resolved "https://registry.yarnpkg.com/rollup-plugin-peer-deps-external/-/rollup-plugin-peer-deps-external-2.2.4.tgz#8a420bbfd6dccc30aeb68c9bf57011f2f109570d" - integrity sha512-AWdukIM1+k5JDdAqV/Cxd+nejvno2FVLVeZ74NKggm3Q5s9cbbcOgUPGdbxPi4BXu7xGaZ8HG12F+thImYu/0g== - -rollup-plugin-typescript2@^0.31.1: - version "0.31.2" - resolved "https://registry.yarnpkg.com/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.31.2.tgz#463aa713a7e2bf85b92860094b9f7fb274c5a4d8" - integrity sha512-hRwEYR1C8xDGVVMFJQdEVnNAeWRvpaY97g5mp3IeLnzhNXzSVq78Ye/BJ9PAaUfN4DXa/uDnqerifMOaMFY54Q== - dependencies: - "@rollup/pluginutils" "^4.1.2" - "@yarn-tool/resolve-package" "^1.0.40" - find-cache-dir "^3.3.2" - fs-extra "^10.0.0" - resolve "^1.20.0" - tslib "^2.3.1" - -rollup@^2.61.1: - version "2.79.1" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7" - integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw== + version "1.1.0" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" + integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== + +rolldown-plugin-dts@^0.13.11: + version "0.13.11" + resolved "https://registry.yarnpkg.com/rolldown-plugin-dts/-/rolldown-plugin-dts-0.13.11.tgz#25ca436174c4723510d9e00f28815394ef6a089d" + integrity sha512-1TScN31JImk8xcq9kdm52z2W8/QX3zeDpEjFkyZmK+GcD0u8QqSWWARBsCEdfS99NyI6D9NIbUpsABXlcpZhig== + dependencies: + "@babel/generator" "^7.27.5" + "@babel/parser" "^7.27.5" + "@babel/types" "^7.27.6" + ast-kit "^2.1.0" + birpc "^2.3.0" + debug "^4.4.1" + dts-resolver "^2.1.1" + get-tsconfig "^4.10.1" + +rolldown@1.0.0-beta.15: + version "1.0.0-beta.15" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-beta.15.tgz#746566a3e434d40af5abfb080e8c6f02bfe90b17" + integrity sha512-ep788NsIGl0W5gT+99hBrSGe4Hdhcwc55PqM3O0mR5H0C4ZpGpDGgu9YzTJ8a6mFDLnFnc/LYC+Dszb7oWK/dg== + dependencies: + "@oxc-project/runtime" "=0.72.3" + "@oxc-project/types" "=0.72.3" + "@rolldown/pluginutils" "1.0.0-beta.15" + ansis "^4.0.0" optionalDependencies: - fsevents "~2.3.2" + "@rolldown/binding-darwin-arm64" "1.0.0-beta.15" + "@rolldown/binding-darwin-x64" "1.0.0-beta.15" + "@rolldown/binding-freebsd-x64" "1.0.0-beta.15" + "@rolldown/binding-linux-arm-gnueabihf" "1.0.0-beta.15" + "@rolldown/binding-linux-arm64-gnu" "1.0.0-beta.15" + "@rolldown/binding-linux-arm64-musl" "1.0.0-beta.15" + "@rolldown/binding-linux-x64-gnu" "1.0.0-beta.15" + "@rolldown/binding-linux-x64-musl" "1.0.0-beta.15" + "@rolldown/binding-wasm32-wasi" "1.0.0-beta.15" + "@rolldown/binding-win32-arm64-msvc" "1.0.0-beta.15" + "@rolldown/binding-win32-ia32-msvc" "1.0.0-beta.15" + "@rolldown/binding-win32-x64-msvc" "1.0.0-beta.15" run-parallel@^1.1.9: version "1.2.0" @@ -2408,7 +3320,7 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: +semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== @@ -2418,6 +3330,11 @@ semver@^7.5.3, semver@^7.5.4: resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== +semver@^7.6.0, semver@^7.7.2: + version "7.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -2430,15 +3347,15 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -signal-exit@^3.0.3, signal-exit@^3.0.7: +signal-exit@^3.0.3: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== -sisteransi@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" - integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== slash@^3.0.0: version "3.0.0" @@ -2453,29 +3370,24 @@ source-map-support@0.5.13: buffer-from "^1.0.0" source-map "^0.6.0" -source-map@^0.6.0, source-map@^0.6.1: +source-map@^0.6.0: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -sourcemap-codec@^1.4.8: - version "1.4.8" - resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" - integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== - sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== -stack-utils@^2.0.3: +stack-utils@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== dependencies: escape-string-regexp "^2.0.0" -string-length@^4.0.1: +string-length@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" integrity sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== @@ -2483,6 +3395,15 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -2492,6 +3413,22 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -2499,6 +3436,13 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" +strip-ansi@^7.0.1: + version "7.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + strip-bom@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" @@ -2528,17 +3472,19 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -supports-color@^8.0.0: +supports-color@^8.1.1: version "8.1.1" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== dependencies: has-flag "^4.0.0" -supports-preserve-symlinks-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" - integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +synckit@^0.11.8: + version "0.11.8" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.8.tgz#b2aaae998a4ef47ded60773ad06e7cb821f55457" + integrity sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A== + dependencies: + "@pkgr/core" "^0.2.4" test-exclude@^6.0.0: version "6.0.0" @@ -2549,6 +3495,19 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +tinyexec@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.1.tgz#70c31ab7abbb4aea0a24f55d120e5990bfa1e0b1" + integrity sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw== + +tinyglobby@^0.2.14: + version "0.2.14" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d" + integrity sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ== + dependencies: + fdir "^6.4.4" + picomatch "^4.0.2" + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -2566,24 +3525,56 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" -ts-jest@^29.0.3: - version "29.1.4" - resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.1.4.tgz#26f8a55ce31e4d2ef7a1fd47dc7fa127e92793ef" - integrity sha512-YiHwDhSvCiItoAgsKtoLFCuakDzDsJ1DLDnSouTaTmdOcOwIkSzbLXduaQ6M5DRVhuZC/NYaaZ/mtHbWMv/S6Q== +ts-api-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" + integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== + +ts-jest@^29.4.0: + version "29.4.0" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.4.0.tgz#bef0ee98d94c83670af7462a1617bf2367a83740" + integrity sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q== dependencies: - bs-logger "0.x" - fast-json-stable-stringify "2.x" - jest-util "^29.0.0" + bs-logger "^0.2.6" + ejs "^3.1.10" + fast-json-stable-stringify "^2.1.0" json5 "^2.2.3" - lodash.memoize "4.x" - make-error "1.x" - semver "^7.5.3" - yargs-parser "^21.0.1" + lodash.memoize "^4.1.2" + make-error "^1.3.6" + semver "^7.7.2" + type-fest "^4.41.0" + yargs-parser "^21.1.1" -tslib@^2, tslib@^2.3.1: - version "2.6.3" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" - integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== +tsdown@^0.12.8: + version "0.12.8" + resolved "https://registry.yarnpkg.com/tsdown/-/tsdown-0.12.8.tgz#247c590b55e3bc0b2d776f2dc482e218c38d2fb5" + integrity sha512-niHeVcFCNjvVZYVGTeoM4BF+/DWxP8pFH2tUs71sEKYdcKtJIbkSdEmtxByaRZeMgwVbVgPb8nv9i9okVwFLAA== + dependencies: + ansis "^4.1.0" + cac "^6.7.14" + chokidar "^4.0.3" + debug "^4.4.1" + diff "^8.0.2" + empathic "^1.1.0" + hookable "^5.5.3" + rolldown "1.0.0-beta.15" + rolldown-plugin-dts "^0.13.11" + semver "^7.7.2" + tinyexec "^1.0.1" + tinyglobby "^0.2.14" + unconfig "^7.3.2" + +tslib@^2.4.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" type-detect@4.0.8: version "4.0.8" @@ -2595,35 +3586,64 @@ type-fest@^0.21.3: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== -typescript@^4.5.3: - version "4.9.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== +type-fest@^4.41.0: + version "4.41.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" + integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== + +typescript-eslint@^8.35.0: + version "8.35.0" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.35.0.tgz#65afcdde973614b8f44fa89293919420ca9b904e" + integrity sha512-uEnz70b7kBz6eg/j0Czy6K5NivaYopgxRjsnAJ2Fx5oTLo3wefTHIbL7AkQr1+7tJCRVpTs/wiM8JR/11Loq9A== + dependencies: + "@typescript-eslint/eslint-plugin" "8.35.0" + "@typescript-eslint/parser" "8.35.0" + "@typescript-eslint/utils" "8.35.0" + +typescript@^5.8.3: + version "5.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" + integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== + +unconfig@^7.3.2: + version "7.3.2" + resolved "https://registry.yarnpkg.com/unconfig/-/unconfig-7.3.2.tgz#170a34ee9b86cec5aaec953260d2da864218b998" + integrity sha512-nqG5NNL2wFVGZ0NA/aCFw0oJ2pxSf1lwg4Z5ill8wd7K4KX/rQbHlwbh+bjctXL5Ly1xtzHenHGOK0b+lG6JVg== + dependencies: + "@quansync/fs" "^0.1.1" + defu "^6.1.4" + jiti "^2.4.2" + quansync "^0.2.8" undici-types@~5.26.4: version "5.26.5" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== -universalify@^0.1.0: - version "0.1.2" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" - integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== - -universalify@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" - integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== - -upath2@^3.1.13: - version "3.1.19" - resolved "https://registry.yarnpkg.com/upath2/-/upath2-3.1.19.tgz#d987d34a62b2daad1c54a692fd5a720a30c9a786" - integrity sha512-d23dQLi8nDWSRTIQwXtaYqMrHuca0As53fNiTLLFDmsGBbepsZepISaB2H1x45bDFN/n3Qw9bydvyZEacTrEWQ== +unrs-resolver@^1.7.11: + version "1.8.1" + resolved "https://registry.yarnpkg.com/unrs-resolver/-/unrs-resolver-1.8.1.tgz#f84ce4aee9ffc2d6eaad497178e0a996bc18433c" + integrity sha512-M5++xH5Tu/m3NNAc0+dBHidXfF6bTC08mfhQ3AB5UTonEzQSH9ASC/a7EbZN3WU5m0OWMTvf12GHVJZ3uUmPtA== dependencies: - "@types/node" "*" - path-is-network-drive "^1.0.20" - path-strip-sep "^1.0.17" - tslib "^2" + napi-postinstall "^0.2.2" + optionalDependencies: + "@unrs/resolver-binding-darwin-arm64" "1.8.1" + "@unrs/resolver-binding-darwin-x64" "1.8.1" + "@unrs/resolver-binding-freebsd-x64" "1.8.1" + "@unrs/resolver-binding-linux-arm-gnueabihf" "1.8.1" + "@unrs/resolver-binding-linux-arm-musleabihf" "1.8.1" + "@unrs/resolver-binding-linux-arm64-gnu" "1.8.1" + "@unrs/resolver-binding-linux-arm64-musl" "1.8.1" + "@unrs/resolver-binding-linux-ppc64-gnu" "1.8.1" + "@unrs/resolver-binding-linux-riscv64-gnu" "1.8.1" + "@unrs/resolver-binding-linux-riscv64-musl" "1.8.1" + "@unrs/resolver-binding-linux-s390x-gnu" "1.8.1" + "@unrs/resolver-binding-linux-x64-gnu" "1.8.1" + "@unrs/resolver-binding-linux-x64-musl" "1.8.1" + "@unrs/resolver-binding-wasm32-wasi" "1.8.1" + "@unrs/resolver-binding-win32-arm64-msvc" "1.8.1" + "@unrs/resolver-binding-win32-ia32-msvc" "1.8.1" + "@unrs/resolver-binding-win32-x64-msvc" "1.8.1" update-browserslist-db@^1.0.16: version "1.0.16" @@ -2633,6 +3653,21 @@ update-browserslist-db@^1.0.16: escalade "^3.1.2" picocolors "^1.0.1" +update-browserslist-db@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420" + integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + v8-to-istanbul@^9.0.1: version "9.2.0" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz#2ed7644a245cddd83d4e087b9b33b3e62dfd10ad" @@ -2656,6 +3691,20 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" @@ -2665,18 +3714,27 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== -write-file-atomic@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz#a9df01ae5b77858a027fd2e80768ee433555fcfd" - integrity sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg== +write-file-atomic@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-5.0.1.tgz#68df4717c55c6fa4281a7860b4c2ba0a6d2b11e7" + integrity sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw== dependencies: imurmurhash "^0.1.4" - signal-exit "^3.0.7" + signal-exit "^4.0.1" y18n@^5.0.5: version "5.0.8" @@ -2688,12 +3746,12 @@ yallist@^3.0.2: resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== -yargs-parser@^21.0.1, yargs-parser@^21.1.1: +yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== -yargs@^17.3.1: +yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==