Skip to content

Commit 4717323

Browse files
committed
refactor/fix/feat(types tests)
1 parent 7d2a480 commit 4717323

File tree

346 files changed

+7831
-5149
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

346 files changed

+7831
-5149
lines changed

.github/scripts/Utils.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { readdir } from 'fs/promises';
2+
3+
export interface ScanDirectoryOptions {
4+
goDeep?: boolean;
5+
replaceSrc?: boolean;
6+
}
7+
8+
export async function scanDirectory(directoryPath: string, options?: ScanDirectoryOptions): Promise<string[]> {
9+
const parsedOptions: ScanDirectoryOptions = {
10+
goDeep: options?.goDeep ?? true,
11+
replaceSrc: options?.replaceSrc ?? false
12+
};
13+
const filePaths: string[] = [];
14+
const files = await readdir(directoryPath, { withFileTypes: true });
15+
16+
for (const file of files) {
17+
const fullPath = directoryPath + file.name;
18+
if (file.isDirectory() && parsedOptions.goDeep) {
19+
const paths = await scanDirectory(`${fullPath}/`, parsedOptions);
20+
paths.forEach((path) => filePaths.push(path));
21+
} else {
22+
if (fullPath.endsWith('.test.ts')) continue;
23+
if (fullPath.endsWith('index.ts')) continue;
24+
filePaths.push(fullPath.replaceAll('./src/', parsedOptions.replaceSrc ? './' : './src/'));
25+
}
26+
}
27+
28+
return filePaths;
29+
}

.github/scripts/checkCoverage.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
/* v8 ignore next 1000 */
2-
31
import { parseStringPromise } from 'xml2js';
42
import { readFileSync } from 'fs';
53

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import {
2+
ClassDeclaration,
3+
Declaration,
4+
ImportDeclaration,
5+
LeftHandSideExpression,
6+
MethodDeclaration,
7+
ModifierFlags,
8+
Node,
9+
ParameterDeclaration,
10+
PropertyDeclaration,
11+
ScriptTarget,
12+
SourceFile,
13+
SyntaxKind,
14+
createProgram,
15+
createSourceFile,
16+
forEachChild,
17+
getCombinedModifierFlags,
18+
isClassDeclaration,
19+
isEnumDeclaration,
20+
isFunctionDeclaration,
21+
isIdentifier,
22+
isImportDeclaration,
23+
isInterfaceDeclaration,
24+
isMethodDeclaration,
25+
isNamedImports,
26+
isPropertyDeclaration,
27+
isQualifiedName,
28+
isReturnStatement,
29+
isTypeAliasDeclaration,
30+
isVariableStatement
31+
} from 'typescript';
32+
import { format } from 'prettier';
33+
import { readFileSync, writeFileSync } from 'node:fs';
34+
import { scanDirectory } from './Utils';
35+
36+
const prettierConfig = JSON.parse(readFileSync('.prettierrc').toString('utf-8'));
37+
const primitiveTypes = new Set(['string', 'number', 'boolean', 'any', 'unknown', 'void']);
38+
39+
interface ParsedProperty {
40+
name: string;
41+
type: string;
42+
kind: 'primitive' | 'class';
43+
}
44+
45+
interface ParsedImport {
46+
module: string;
47+
named?: string[];
48+
default?: string;
49+
}
50+
51+
interface ParsedMethod {
52+
name: string;
53+
returnType: string;
54+
parameters: ParsedProperty[];
55+
body?: string;
56+
}
57+
58+
interface ParsedClass {
59+
className: string;
60+
properties: ParsedProperty[];
61+
methods: ParsedMethod[];
62+
imports: ParsedImport[];
63+
extending: string;
64+
}
65+
66+
function handleImportDeclaration(sourceFile: SourceFile, node: ImportDeclaration): ParsedImport {
67+
const importObj: ParsedImport = { module: node.moduleSpecifier.getText(sourceFile).replace(/['"]/g, '') };
68+
if (!node.importClause) return importObj;
69+
if (node.importClause.name) importObj.default = node.importClause.name.getText(sourceFile);
70+
if (!node.importClause.namedBindings) return importObj;
71+
if (!isNamedImports(node.importClause.namedBindings)) return importObj;
72+
importObj.named = node.importClause.namedBindings.elements.map((element) => element.name.getText(sourceFile));
73+
return importObj;
74+
}
75+
76+
function handlePropertyParameterDeclaration(
77+
sourceFile: SourceFile,
78+
property: PropertyDeclaration | ParameterDeclaration
79+
): ParsedProperty {
80+
let type = 'any';
81+
if (property.type) type = property.type.getText(sourceFile);
82+
return { name: property.name.getText(sourceFile), type, kind: primitiveTypes.has(type) ? 'primitive' : 'class' };
83+
}
84+
85+
function extractReturnBody(sourceFile: SourceFile, method: MethodDeclaration): string | undefined {
86+
if (!method.body) return undefined;
87+
const returnStmt = method.body.statements.find((statements) => isReturnStatement(statements));
88+
if (!returnStmt) return undefined;
89+
const expression = returnStmt.expression;
90+
if (!expression) return undefined;
91+
return expression.getText(sourceFile);
92+
}
93+
94+
function handleMethodDeclaration(sourceFile: SourceFile, method: MethodDeclaration): ParsedMethod {
95+
let returnType = 'void';
96+
if (method.type) returnType = method.type.getText(sourceFile);
97+
const parameters = method.parameters.map((parameters) => handlePropertyParameterDeclaration(sourceFile, parameters));
98+
const info: ParsedMethod = { name: method.name.getText(sourceFile), returnType, parameters };
99+
if (info.name === 'toString') info.body = extractReturnBody(sourceFile, method);
100+
return info;
101+
}
102+
103+
function getExtendingName(expr: LeftHandSideExpression): string {
104+
if (isIdentifier(expr)) return expr.text;
105+
if (isQualifiedName(expr)) return expr.right.text;
106+
return 'UNKNOWN';
107+
}
108+
109+
function handleClassDeclaration(
110+
sourceFile: SourceFile,
111+
node: ClassDeclaration
112+
): { properties: ParsedProperty[]; methods: ParsedMethod[]; extending: string } {
113+
let extending: string = '';
114+
if (node.heritageClauses) {
115+
for (const clause of node.heritageClauses) {
116+
if (clause.token === SyntaxKind.ExtendsKeyword) {
117+
const type = clause.types[0];
118+
if (type) extending = getExtendingName(type.expression);
119+
}
120+
}
121+
}
122+
123+
const properties: ParsedProperty[] = [];
124+
const methods: ParsedMethod[] = [];
125+
for (const member of node.members) {
126+
if (isPropertyDeclaration(member)) properties.push(handlePropertyParameterDeclaration(sourceFile, member));
127+
if (isMethodDeclaration(member)) methods.push(handleMethodDeclaration(sourceFile, member));
128+
}
129+
return { properties, methods, extending };
130+
}
131+
132+
function getTypes(filePath: string): ParsedClass {
133+
const imports: ParsedImport[] = [];
134+
let className = 'UNKNOWN';
135+
let extending = 'UNKNOWN';
136+
const properties: ParsedProperty[] = [];
137+
const methods: ParsedMethod[] = [];
138+
139+
const fileContent = readFileSync(filePath, 'utf-8');
140+
const sourceFile = createSourceFile(filePath, fileContent, ScriptTarget.Latest, true);
141+
function visit(node: Node) {
142+
if (isClassDeclaration(node) && node.name) className = node.name.text;
143+
if (isClassDeclaration(node)) {
144+
const classData = handleClassDeclaration(sourceFile, node);
145+
extending = classData.extending;
146+
classData.properties.forEach((property) => properties.push(property));
147+
classData.methods.forEach((method) => methods.push(method));
148+
}
149+
if (isImportDeclaration(node)) imports.push(handleImportDeclaration(sourceFile, node));
150+
forEachChild(node, visit);
151+
}
152+
153+
visit(sourceFile);
154+
return { className, properties, methods, imports, extending };
155+
}
156+
157+
function getFileName(filePath: string): string {
158+
return filePath.split('/').pop() || 'UNKNOWN';
159+
}
160+
161+
function getParentDirPath(filePath: string): string {
162+
return filePath.replaceAll(getFileName(filePath), '');
163+
}
164+
165+
function getParentDirName(filePath: string): string {
166+
return getParentDirPath(filePath).slice(0, -1).split('/').reverse()[0];
167+
}
168+
169+
const ignoredImportsFolders = ['Utils', 'Private'];
170+
const allowedMethods = ['toString', 'toHex', 'toCode', 'toInGameCode'];
171+
172+
function parseTypeName(name: string): string {
173+
if (name.includes("'")) return `[${name}]`;
174+
return `.${name}`;
175+
}
176+
177+
function getExportedNames(filePath: string): string[] {
178+
const program = createProgram([filePath], {});
179+
const sourceFile = program.getSourceFile(filePath)!;
180+
181+
const exportedNames: string[] = [];
182+
183+
function visit(node: Node) {
184+
const isExported = (getCombinedModifierFlags(node as Declaration) & ModifierFlags.Export) !== 0;
185+
186+
if (isExported) {
187+
if (
188+
isInterfaceDeclaration(node) ||
189+
isTypeAliasDeclaration(node) ||
190+
isClassDeclaration(node) ||
191+
isEnumDeclaration(node) ||
192+
isFunctionDeclaration(node) ||
193+
isVariableStatement(node)
194+
) {
195+
if (isVariableStatement(node)) {
196+
for (const decl of node.declarationList.declarations) {
197+
if (isIdentifier(decl.name)) exportedNames.push(decl.name.text);
198+
}
199+
} else if (node.name && isIdentifier(node.name)) {
200+
exportedNames.push(node.name.text);
201+
}
202+
}
203+
}
204+
205+
forEachChild(node, visit);
206+
}
207+
208+
visit(sourceFile);
209+
210+
return exportedNames;
211+
}
212+
213+
function validTypeIsClass(type: string, ignoredTypes: string[]): boolean {
214+
if (type.includes('{')) return false;
215+
if (type.includes('[')) return false;
216+
if (type.includes('|')) return false;
217+
if (type.includes("'")) return false;
218+
if (type.includes('"')) return false;
219+
if (type.includes('<')) return false;
220+
if (type.includes(',')) return false;
221+
if (type.includes(';')) return false;
222+
if (type.includes(' ')) return false;
223+
if (ignoredTypes.includes(type)) return false;
224+
return true;
225+
}
226+
227+
async function parseFile(filePath: string, ignoredTypes: string[]) {
228+
console.log(`Generating Test for ${filePath}`);
229+
const types = getTypes(filePath);
230+
if (types === null) return;
231+
const imports: string[] = [
232+
'/* eslint-disable @stylistic/max-len */',
233+
`import ${types.className} from './${types.className}.js';`
234+
];
235+
const typedImports: string[] = [];
236+
types.imports.forEach((fileImport) => {
237+
fileImport.module = fileImport.module.replaceAll('.js', '.ts');
238+
if (ignoredImportsFolders.includes(getParentDirName(fileImport.module))) return;
239+
fileImport.module = fileImport.module.replaceAll('.ts', '.js');
240+
if (types.extending !== '' && types.extending === fileImport.default) return;
241+
if (fileImport.default) imports.push(`import ${fileImport.default} from '${fileImport.module}'`);
242+
if (fileImport.named) typedImports.push(`import type {${fileImport.named.join(', ')}} from '${fileImport.module}'`);
243+
});
244+
imports.push("import { expect, expectTypeOf, test } from 'vitest';");
245+
const testCode: string[] = [
246+
`test('${types.className}', () => {`,
247+
`const data = new ${types.className}({ stats: 'meow' });`,
248+
'expect(data).toBeDefined();',
249+
`expect(data).toBeInstanceOf(${types.className});`,
250+
`expectTypeOf(data).toEqualTypeOf<${types.className}>();`
251+
];
252+
types.properties.forEach((type) => {
253+
type.name = parseTypeName(type.name);
254+
testCode.push(`expect(data${type.name}).toBeDefined();`);
255+
if (type.type === 'number') testCode.push(`expect(data${type.name}).toBeGreaterThanOrEqual(0);`);
256+
if (type.kind === 'class' && validTypeIsClass(type.type, ignoredTypes)) {
257+
testCode.push(`expect(data${type.name}).toBeInstanceOf(${type.type});`);
258+
}
259+
testCode.push(`expectTypeOf(data${type.name}).toEqualTypeOf<${type.type}>();`);
260+
});
261+
types.methods.forEach((method) => {
262+
if (!allowedMethods.includes(method.name)) return;
263+
testCode.push(`expect(data.${method.name}).toBeDefined();`);
264+
testCode.push(`expectTypeOf(data.${method.name}).toEqualTypeOf<() => ${method.returnType}>();`);
265+
testCode.push(`expect(data.${method.name}()).toBeDefined();`);
266+
if (method.name === 'toString' && method.body) {
267+
testCode.push(`expect(data.${method.name}()).toBe(${method.body.replaceAll('this.', 'data.')});`);
268+
}
269+
testCode.push(`expectTypeOf(data.${method.name}()).toEqualTypeOf<${method.returnType}>();`);
270+
});
271+
testCode.push('});');
272+
273+
filePath = filePath.replaceAll('.ts', '.test.ts');
274+
const joinedString = [...imports, ...typedImports, '/* eslint-enable @stylistic/max-len */', '', ...testCode]
275+
.join('\n')
276+
.replaceAll('\n\n\n', '\n');
277+
const formatted = await format(joinedString, { ...prettierConfig, filepath: filePath });
278+
writeFileSync(filePath, formatted);
279+
console.log(`Generated Test for ${filePath}\n`);
280+
}
281+
282+
async function getIgnoredTypes(): Promise<string[]> {
283+
const ignoredTypes: string[] = [];
284+
const typesPaths = await scanDirectory('./src/Types/');
285+
for (const file of typesPaths) {
286+
getExportedNames(file).forEach((type) => {
287+
if (!ignoredTypes.includes(type)) ignoredTypes.push(type);
288+
});
289+
}
290+
291+
return ignoredTypes;
292+
}
293+
294+
(async () => {
295+
const ignoredTypes = await getIgnoredTypes();
296+
const structuresPaths = await scanDirectory('./src/Structures/');
297+
for (const file of structuresPaths) {
298+
await parseFile(file, ignoredTypes);
299+
}
300+
})();

0 commit comments

Comments
 (0)