Repository: asyncapi/modelina (ts)

Issue:
Special cases in enum values fails to generate accurate enum output
#### Describe the bug
Given the schema:
```
{ "enum": ["medium", "medium+"] } 
```
It would generate the enum output 

```
export enum X {
  MEDIUM = "medium",
  MEDIUM = "medium+"
}
```

Which cannot happen as no duplicate keys may be generated

Pull request description:
fix: special characters in enum values
**Description**
I added a Helper Function to resolve ascii characters that are not numbers or letters ([0-9a-zA-Z]) and translate them to english. - becomes minus, + becomes plus ...
The function also accepts characters to be ignored and a seperator string to seperate the translated characters from the rest.
Applied this function to the enum renderer to resolve the issue

**Related issue(s)**
Fixes #450

**Tests**
In #450 a blacklisted test is mentioned

Patch:
diff --git a/src/generators/typescript/renderers/EnumRenderer.ts b/src/generators/typescript/renderers/EnumRenderer.ts
index cbefb5fb..ca1957ca 100644
--- a/src/generators/typescript/renderers/EnumRenderer.ts
+++ b/src/generators/typescript/renderers/EnumRenderer.ts
@@ -50,7 +50,7 @@ ${this.indent(this.renderBlock(content, 2))}
       break;
     }
     default: {
-      key = String(value);
+      key = FormatHelpers.replaceSpecialCharacters(String(value), { exclude: [' '], separator: '_' });
       //Ensure no special char can be the beginning letter 
       if (!(/^[a-zA-Z]+$/).test(key.charAt(0))) {
         key = `String_${key}`;
diff --git a/src/helpers/FormatHelpers.ts b/src/helpers/FormatHelpers.ts
index 02edaccf..26232ba9 100644
--- a/src/helpers/FormatHelpers.ts
+++ b/src/helpers/FormatHelpers.ts
@@ -1,4 +1,4 @@
-import { 
+import {
   camelCase,
   pascalCase,
   paramCase,
@@ -10,6 +10,44 @@ export enum IndentationTypes {
   SPACES = 'spaces',
 }
 
+const specialCharacterReplacements = new Map<string, string>([
+  [' ', 'space'],
+  ['!', 'exclamation'],
+  ['"', 'quotation'],
+  ['#', 'hash'],
+  ['$', 'dollar'],
+  ['%', 'percent'],
+  ['&', 'ampersand'],
+  ['\'', 'apostrophe'],
+  ['(', 'roundleft'],
+  [')', 'roundright'],
+  ['*', 'asterisk'],
+  ['+', 'plus'],
+  [',', 'comma'],
+  ['-', 'minus'],
+  ['.', 'dot'],
+  ['/', 'slash'],
+  [':', 'colon'],
+  [';', 'semicolon'],
+  ['<', 'less'],
+  ['=', 'equal'],
+  ['>', 'greater'],
+  ['?', 'question'],
+  ['@', 'at'],
+  ['[', 'squareleft'],
+  ['\\', 'backslash'],
+  [']', 'squareright'],
+  ['^', 'circumflex'],
+  ['_', 'underscore'],
+  ['`', 'graveaccent'],
+  ['{', 'curlyleft'],
+  ['|', 'vertical'],
+  ['}', 'curlyright'],
+  ['~', 'tilde'],
+]);
+
+interface ReplaceSpecialCharactersOptions { separator?: string, exclude?: string[] }
+
 export class FormatHelpers {
   /**
    * Upper first char in given string value.
@@ -48,6 +86,22 @@ export class FormatHelpers {
    */
   static toConstantCase = constantCase;
 
+  /**
+  * Replace special characters (Not 0-9,a-z,A-Z) with character names
+  * @param {string} value to transform
+  * @param {ReplaceSpecialCharactersOptions} options
+  * @returns {string}
+  */
+  static replaceSpecialCharacters(string: string, options?: ReplaceSpecialCharactersOptions): string {
+    const separator = options?.separator ?? '';
+    return [...string].reduce((sum: string, c: string, i: number) => {
+      if (options?.exclude?.includes(c)) { return sum + c; }
+      const replacement = specialCharacterReplacements.get(c);
+      if (replacement === undefined) { return sum + c; }
+      return sum + (sum.endsWith(separator) || sum.length === 0 ? '' : separator) + replacement + (i === string.length - 1 ? '' : separator);
+    }, '');
+  }
+
   /**
    * Ensures breaking text into new lines according to newline char (`\n`) in text.
    * @param {(string | string[])} lines to breaks
@@ -103,7 +157,7 @@ export class FormatHelpers {
     let renderedExamples = '';
     if (Array.isArray(examples)) {
       for (const example of examples) {
-        if (renderedExamples !== '') {renderedExamples += ', ';}
+        if (renderedExamples !== '') { renderedExamples += ', '; }
         if (typeof example === 'object') {
           try {
             renderedExamples += JSON.stringify(example);


Test patch:
diff --git a/test/generators/typescript/TypeScriptGenerator.spec.ts b/test/generators/typescript/TypeScriptGenerator.spec.ts
index 33fb7387..a98d5392 100644
--- a/test/generators/typescript/TypeScriptGenerator.spec.ts
+++ b/test/generators/typescript/TypeScriptGenerator.spec.ts
@@ -1,4 +1,4 @@
-import { TypeScriptGenerator } from '../../../src/generators'; 
+import { TypeScriptGenerator } from '../../../src/generators';
 
 describe('TypeScriptGenerator', () => {
   let generator: TypeScriptGenerator;
@@ -175,20 +175,22 @@ describe('TypeScriptGenerator', () => {
   set additionalProperties(additionalProperties: Map<String, object | string | number | Array<unknown> | boolean | null> | undefined) { this._additionalProperties = additionalProperties; }
 }`;
 
-    generator = new TypeScriptGenerator({ presets: [
-      {
-        class: {
-          property({ propertyName, content }) {
-            return `@JsonProperty("${propertyName}")
+    generator = new TypeScriptGenerator({
+      presets: [
+        {
+          class: {
+            property({ propertyName, content }) {
+              return `@JsonProperty("${propertyName}")
 ${content}`;
-          },
+            },
+          }
         }
-      }
-    ] });
+      ]
+    });
 
     const inputModel = await generator.process(doc);
     const model = inputModel.models['CustomClass'];
-    
+
     const classModel = await generator.render(model, inputModel);
     expect(classModel.result).toEqual(expected);
     expect(classModel.dependencies).toEqual([]);
@@ -230,7 +232,7 @@ ${content}`;
   sTestPatternProperties?: Map<String, string>;
 }`;
 
-    const interfaceGenerator = new TypeScriptGenerator({modelType: 'interface'});
+    const interfaceGenerator = new TypeScriptGenerator({ modelType: 'interface' });
     const inputModel = await interfaceGenerator.process(doc);
     const model = inputModel.models['Address'];
 
@@ -252,15 +254,17 @@ ${content}`;
   additionalProperties?: Map<String, object | string | number | Array<unknown> | boolean | null>;
 }`;
 
-    generator = new TypeScriptGenerator({ presets: [
-      {
-        interface: {
-          self({ content }) {
-            return content;
-          },
+    generator = new TypeScriptGenerator({
+      presets: [
+        {
+          interface: {
+            self({ content }) {
+              return content;
+            },
+          }
         }
-      }
-    ] });
+      ]
+    });
 
     const inputModel = await generator.process(doc);
     const model = inputModel.models['CustomInterface'];
@@ -288,7 +292,7 @@ ${content}`;
     let enumModel = await generator.render(model, inputModel);
     expect(enumModel.result).toEqual(expected);
     expect(enumModel.dependencies).toEqual([]);
-    
+
     enumModel = await generator.renderEnum(model, inputModel);
     expect(enumModel.result).toEqual(expected);
     expect(enumModel.dependencies).toEqual([]);
@@ -302,14 +306,14 @@ ${content}`;
     };
     const expected = 'export type States = "Texas" | "Alabama" | "California";';
 
-    const unionGenerator = new TypeScriptGenerator({enumType: 'union'});
+    const unionGenerator = new TypeScriptGenerator({ enumType: 'union' });
     const inputModel = await unionGenerator.process(doc);
     const model = inputModel.models['States'];
 
     let enumModel = await unionGenerator.render(model, inputModel);
     expect(enumModel.result).toEqual(expected);
     expect(enumModel.dependencies).toEqual([]);
-    
+
     enumModel = await unionGenerator.renderType(model, inputModel);
     expect(enumModel.result).toEqual(expected);
     expect(enumModel.dependencies).toEqual([]);
@@ -318,7 +322,7 @@ ${content}`;
   test('should render union `enum` values', async () => {
     const doc = {
       $id: 'States',
-      enum: [2, '2', 'test', true, {test: 'test'}]
+      enum: [2, '2', 'test', true, { test: 'test' }]
     };
     const expected = `export enum States {
   NUMBER_2 = 2,
@@ -334,7 +338,32 @@ ${content}`;
     let enumModel = await generator.render(model, inputModel);
     expect(enumModel.result).toEqual(expected);
     expect(enumModel.dependencies).toEqual([]);
-    
+
+    enumModel = await generator.renderEnum(model, inputModel);
+    expect(enumModel.result).toEqual(expected);
+    expect(enumModel.dependencies).toEqual([]);
+  });
+
+  test('should render enums with translated special characters', async () => {
+    const doc = {
+      $id: 'States',
+      enum: ['test+', 'test', 'test-', 'test?!', '*test']
+    };
+    const expected = `export enum States {
+  TEST_PLUS = "test+",
+  TEST = "test",
+  TEST_MINUS = "test-",
+  TEST_QUESTION_EXCLAMATION = "test?!",
+  ASTERISK_TEST = "*test",
+}`;
+
+    const inputModel = await generator.process(doc);
+    const model = inputModel.models['States'];
+
+    let enumModel = await generator.render(model, inputModel);
+    expect(enumModel.result).toEqual(expected);
+    expect(enumModel.dependencies).toEqual([]);
+
     enumModel = await generator.renderEnum(model, inputModel);
     expect(enumModel.result).toEqual(expected);
     expect(enumModel.dependencies).toEqual([]);
@@ -352,23 +381,25 @@ ${content}`;
   CALIFORNIA = "California",
 }`;
 
-    generator = new TypeScriptGenerator({ presets: [
-      {
-        enum: {
-          self({ content }) {
-            return content;
-          },
+    generator = new TypeScriptGenerator({
+      presets: [
+        {
+          enum: {
+            self({ content }) {
+              return content;
+            },
+          }
         }
-      }
-    ] });
+      ]
+    });
 
     const inputModel = await generator.process(doc);
     const model = inputModel.models['CustomEnum'];
-    
+
     let enumModel = await generator.render(model, inputModel);
     expect(enumModel.result).toEqual(expected);
     expect(enumModel.dependencies).toEqual([]);
-    
+
     enumModel = await generator.renderEnum(model, inputModel);
     expect(enumModel.result).toEqual(expected);
     expect(enumModel.dependencies).toEqual([]);
@@ -472,7 +503,7 @@ ${content}`;
         marriage: { type: 'boolean', description: 'Status if marriage live in given house' },
         members: { oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }], },
         array_type: { type: 'array', items: [{ type: 'string' }, { type: 'number' }] },
-        other_model: { type: 'object', $id: 'OtherModel', properties: {street_name: { type: 'string' }} },
+        other_model: { type: 'object', $id: 'OtherModel', properties: { street_name: { type: 'string' } } },
       },
       patternProperties: {
         '^S(.?*)test&': {
@@ -481,7 +512,7 @@ ${content}`;
       },
       required: ['street_name', 'city', 'state', 'house_number', 'array_type'],
     };
-    const models = await generator.generateCompleteModels(doc,{});
+    const models = await generator.generateCompleteModels(doc, {});
     expect(models).toHaveLength(2);
     expect(models[0].result).toMatchSnapshot();
     expect(models[1].result).toMatchSnapshot();
diff --git a/test/helpers/FormatHelpers.spec.ts b/test/helpers/FormatHelpers.spec.ts
index e1d9c26f..7243e4aa 100644
--- a/test/helpers/FormatHelpers.spec.ts
+++ b/test/helpers/FormatHelpers.spec.ts
@@ -1,13 +1,13 @@
-import { FormatHelpers, IndentationTypes } from '../../src/helpers'; 
+import { FormatHelpers, IndentationTypes } from '../../src/helpers';
 
 describe('FormatHelpers', () => {
   describe('breakLines', () => {
-    test('should break single text', () => {  
+    test('should break single text', () => {
       const breakedTexts = FormatHelpers.breakLines('text1\ntext2\ntext3');
       expect(breakedTexts).toHaveLength(3);
       expect(breakedTexts).toStrictEqual(['text1', 'text2', 'text3']);
     });
-    test('should support multiple lines', () => {  
+    test('should support multiple lines', () => {
       const breakedTexts = FormatHelpers.breakLines(['text1', 'text2', 'text3']);
       expect(breakedTexts).toHaveLength(3);
       expect(breakedTexts).toStrictEqual(['text1', 'text2', 'text3']);
@@ -33,16 +33,53 @@ describe('FormatHelpers', () => {
       const content = FormatHelpers.indent('Test', 4, IndentationTypes.SPACES);
       expect(content).toEqual('    Test');
     });
-  
+
     test('should make indentation with tabs', () => {
       const content = FormatHelpers.indent('Test', 2, IndentationTypes.TABS);
       expect(content).toEqual('\t\tTest');
     });
-  
+
     test('should be able to make nest indentation', () => {
       let content = FormatHelpers.indent('Test', 4, IndentationTypes.SPACES);
       content = FormatHelpers.indent(content, 2, IndentationTypes.TABS);
       expect(content).toEqual('\t\t    Test');
     });
   });
+
+  describe('replaceSpecialCharacters', () => {
+    test('should replace any special character', () => {
+      const content = FormatHelpers.replaceSpecialCharacters(' !"#$%');
+      expect(content).toEqual('spaceexclamationquotationhashdollarpercent');
+    });
+
+    test('should surround replaced parts with separators from each other', () => {
+      const content = FormatHelpers.replaceSpecialCharacters('&\'()*+', { separator: '_' });
+      expect(content).toEqual('ampersand_apostrophe_roundleft_roundright_asterisk_plus');
+    });
+
+    test('should surround replaced parts with separators from text', () => {
+      const content = FormatHelpers.replaceSpecialCharacters(',-.test/:;', { separator: '_' });
+      expect(content).toEqual('comma_minus_dot_test_slash_colon_semicolon');
+    });
+
+    test('should surround replaced parts with separators from text at the beginning', () => {
+      const content = FormatHelpers.replaceSpecialCharacters('test<=>?@[', { separator: '_' });
+      expect(content).toEqual('test_less_equal_greater_question_at_squareleft');
+    });
+
+    test('should surround replaced parts with separators from text at the end', () => {
+      const content = FormatHelpers.replaceSpecialCharacters('\\]^_`test', { separator: '_' });
+      expect(content).toEqual('backslash_squareright_circumflex_underscore_graveaccent_test');
+    });
+
+    test('should exclude one special characters if defined', () => {
+      const content = FormatHelpers.replaceSpecialCharacters('{|}~_', { separator: ' ', exclude: ['_'] });
+      expect(content).toEqual('curlyleft vertical curlyright tilde _');
+    });
+
+    test('should exclude many special characters if defined', () => {
+      const content = FormatHelpers.replaceSpecialCharacters('{?_', { separator: ' ', exclude: ['_', '?', '{'] });
+      expect(content).toEqual('{?_');
+    });
+  });
 });