Skip to content

Commit 44677ca

Browse files
authored
Merge pull request #382 from SanjulaGanepola/feature/rpgunit-test-case-generation
Add RPGUnit test case and test suite generation
2 parents 75dd815 + 1540f93 commit 44677ca

File tree

7 files changed

+537
-44
lines changed

7 files changed

+537
-44
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,5 @@ Thanks so much to everyone [who has contributed](https://github.com/codefori/vsc
4444
- [@chrjorgensen](https://github.com/chrjorgensen)
4545
- [@sebjulliand](https://github.com/sebjulliand)
4646
- [@richardm90](https://github.com/richardm90)
47-
- [@wright4i](https://github.com/wright4i)
47+
- [@wright4i](https://github.com/wright4i)
48+
- [@SanjulaGanepola](https://github.com/SanjulaGanepola)

extension/server/src/providers/codeActions.ts

Lines changed: 287 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1-
import { CodeAction, CodeActionKind, CodeActionParams, Position, Range, TextEdit } from 'vscode-languageserver';
1+
import { CodeAction, CodeActionKind, CodeActionParams, CreateFile, Position, Range, TextDocumentEdit, TextEdit, WorkspaceFolder } from 'vscode-languageserver';
22
import { TextDocument } from 'vscode-languageserver-textdocument';
33
import { documents, parser, prettyKeywords } from '.';
4-
import Cache from '../../../../language/models/cache';
4+
import Cache, { RpgleTypeDetail, RpgleVariableType } from '../../../../language/models/cache';
55
import { getLinterCodeActions } from './linter/codeActions';
66
import { createExtract, caseInsensitiveReplaceAll } from './language';
7+
import { Keywords } from '../../../../language/parserTypes';
8+
import path = require('path');
9+
import { getWorkspaceFolder } from '../connection';
10+
import Declaration from '../../../../language/models/declaration';
11+
12+
interface TestCaseSpec {
13+
prototype: string[];
14+
testCase: string[];
15+
includes: string[];
16+
}
717

818
export default async function genericCodeActionsProvider(params: CodeActionParams): Promise<CodeAction[] | undefined> {
919
const uri = params.textDocument.uri;
@@ -33,10 +43,10 @@ export default async function genericCodeActionsProvider(params: CodeActionParam
3343
}
3444
}
3545

36-
// const testCaseOption = getTestCaseAction(document, docs, range);
37-
// if (testCaseOption) {
38-
// actions.push(testCaseOption);
39-
// }
46+
const testActions = await getTestActions(document, docs, range);
47+
if (testActions) {
48+
actions.push(...testActions);
49+
}
4050

4151
const monitorAction = surroundWithMonitorAction(isFree, document, docs, range);
4252
if (monitorAction) {
@@ -48,32 +58,282 @@ export default async function genericCodeActionsProvider(params: CodeActionParam
4858
return actions;
4959
}
5060

51-
export function getTestCaseAction(document: TextDocument, docs: Cache, range: Range): CodeAction | undefined {
52-
const currentProcedure = docs.procedures.find(sub => range.start.line >= sub.position.range.line && sub.range.start && sub.range.end);
53-
if (currentProcedure) {
61+
export async function getTestActions(document: TextDocument, docs: Cache, range: Range): Promise<CodeAction[] | undefined> {
62+
const codeActions: CodeAction[] = [];
63+
64+
const exportProcedures = docs.procedures.filter(proc => proc.keyword[`EXPORT`]);
65+
if (exportProcedures.length > 0) {
66+
const workspaceFolder = await getWorkspaceFolder(document.uri); // TODO: Can workspace folder not be a requirement?
67+
if (workspaceFolder) {
68+
// Build new test file uri
69+
const parsedPath = path.parse(document.uri);
70+
const fileName = parsedPath.base;
71+
const testFileName = `${parsedPath.name}.test${parsedPath.ext}`;
72+
const testFileUri = workspaceFolder ?
73+
`${workspaceFolder.uri}/qtestsrc/${testFileName}` :
74+
`${parsedPath.dir}/${testFileName}`;
75+
76+
// Test case generation
77+
const currentProcedure = exportProcedures.find(sub => sub.range.start && sub.range.end && range.start.line >= sub.range.start && range.end.line <= sub.range.end);
78+
if (currentProcedure) {
79+
const testCaseSpec = await getTestCaseSpec(docs, currentProcedure, workspaceFolder);
80+
const newTestSuite = generateTestSuite([testCaseSpec]);
81+
const testCaseAction = CodeAction.create(`Generate test case for '${currentProcedure.name}'`, CodeActionKind.RefactorExtract);
82+
testCaseAction.edit = {
83+
documentChanges: [
84+
CreateFile.create(testFileUri, { ignoreIfExists: true }),
85+
TextDocumentEdit.create({ uri: testFileUri, version: null }, [TextEdit.insert(Position.create(0, 0), newTestSuite.join(`\n`))])
86+
]
87+
};
88+
codeActions.push(testCaseAction);
89+
}
5490

55-
const refactorAction = CodeAction.create(`Create IBM i test case`, CodeActionKind.RefactorExtract);
91+
// Test suite generation
92+
const newTestCases = await Promise.all(exportProcedures.map(async proc => await getTestCaseSpec(docs, proc, workspaceFolder)));
93+
const newTestSuite = generateTestSuite(newTestCases);
94+
const testSuiteAction = CodeAction.create(`Generate test suite for '${fileName}'`, CodeActionKind.RefactorExtract);
95+
testSuiteAction.edit = {
96+
documentChanges: [
97+
CreateFile.create(testFileUri, { ignoreIfExists: true }),
98+
TextDocumentEdit.create({ uri: testFileUri, version: null }, [TextEdit.insert(Position.create(0, 0), newTestSuite.join(`\n`))])
99+
]
100+
};
101+
codeActions.push(testSuiteAction);
102+
}
103+
}
56104

57-
refactorAction.edit = {
58-
changes: {
59-
['mynewtest.rpgle']: [
60-
TextEdit.insert(
61-
Position.create(0, 0), // Insert at the start of the new test case file
62-
[
63-
`**free`,
64-
``,
65-
`dcl-proc test_${currentProcedure.name.toLowerCase()} export;`,
66-
``,
67-
`end-proc;`
68-
].join(`\n`)
105+
return codeActions;
106+
}
69107

70-
)
71-
]
72-
},
73-
};
74108

75-
return refactorAction;
109+
function generateTestSuite(testCaseSpecs: TestCaseSpec[]) {
110+
const prototypes = testCaseSpecs.map(tc => tc.prototype.length > 0 ? [``, ...tc.prototype] : tc.prototype).flat();
111+
const testCases = testCaseSpecs.map(tc => tc.testCase.length > 0 ? [``, ...tc.testCase] : tc.testCase).flat();
112+
const allIncludes = testCaseSpecs.map(tc => tc.includes).flat();
113+
const uniqueIncludes = [...new Set(allIncludes)];
114+
115+
return [
116+
`**free`,
117+
``,
118+
`ctl-opt nomain;`,
119+
...prototypes,
120+
``,
121+
`/include qinclude,TESTCASE`,
122+
...uniqueIncludes,
123+
...testCases
124+
]
125+
}
126+
127+
async function getTestCaseSpec(docs: Cache, procedure: Declaration, workspaceFolder: WorkspaceFolder): Promise<TestCaseSpec> {
128+
// Get procedure prototype
129+
const prototype = await getPrototype(procedure);
130+
131+
// Get inputs
132+
const inputDecs: string[] = [];
133+
const inputInits: string[] = [];
134+
const inputIncludes: string[] = [];
135+
for (const subItem of procedure.subItems) {
136+
const subItemType = docs.resolveType(subItem);
137+
138+
const subItemDec = getDeclaration(subItemType, `${subItem.name}`);
139+
inputDecs.push(...subItemDec);
140+
141+
const subItemInits = getInitializations(docs, subItemType, `${subItem.name}`);
142+
inputInits.push(...subItemInits);
143+
144+
const subItemIncludes = getIncludes(subItemType, workspaceFolder);
145+
inputIncludes.push(...subItemIncludes);
146+
}
147+
148+
// Get return
149+
const resolvedType = docs.resolveType(procedure);
150+
const actualDec = getDeclaration(resolvedType, 'actual');
151+
const expectedDec = getDeclaration(resolvedType, 'expected');
152+
const expectedInits = getInitializations(docs, resolvedType, 'expected');
153+
const returnIncludes = getIncludes(resolvedType, workspaceFolder);
154+
155+
// Get unique includes
156+
const includes = [...new Set([...inputIncludes, ...returnIncludes])];
157+
158+
// Get assertions
159+
const assertions = getAssertions(docs, resolvedType, 'expected', 'actual');
160+
161+
const testCase = [
162+
`dcl-proc test_${procedure.name} export;`,
163+
` dcl-pi *n extproc(*dclcase) end-pi;`,
164+
``,
165+
...inputDecs.map(dec => ` ${dec}`),
166+
...actualDec.map(dec => ` ${dec}`),
167+
...expectedDec.map(dec => ` ${dec}`),
168+
``,
169+
` // Input`,
170+
...inputInits.map(init => ` ${init}`),
171+
``,
172+
` // Actual results`,
173+
` actual = ${procedure.name}(${procedure.subItems.map(s => s.name).join(` : `)});`,
174+
``,
175+
` // Expected results`,
176+
...expectedInits.map(init => ` ${init}`),
177+
``,
178+
` // Assertions`,
179+
...assertions.map(assert => ` ${assert}`),
180+
`end-proc;`
181+
];
182+
183+
return {
184+
prototype,
185+
testCase,
186+
includes
187+
};
188+
}
189+
190+
function getDeclaration(detail: RpgleTypeDetail, name: string): string[] {
191+
const declarations: string[] = [];
192+
193+
if (detail) {
194+
if (detail.type) {
195+
declarations.push(`dcl-s ${name} ${detail.type.name}${detail.type.value ? `(${detail.type.value})` : ``};`);
196+
} else if (detail.reference) {
197+
declarations.push(`dcl-ds ${name} likeDs(${detail.reference.name});`);
198+
}
76199
}
200+
201+
return declarations;
202+
}
203+
204+
function getInitializations(docs: Cache, detail: RpgleTypeDetail, name: string): string[] {
205+
const inits: string[] = [];
206+
207+
if (detail) {
208+
if (detail.type) {
209+
const defaultValue = getDefaultValue(detail.type.name);
210+
inits.push(`${name} = ${defaultValue};`);
211+
} else if (detail.reference) {
212+
for (const subItem of detail.reference.subItems) {
213+
const subItemType = docs.resolveType(subItem);
214+
const subItemInits = subItemType ?
215+
getInitializations(docs, subItemType, `${name}.${subItem.name}`) : [];
216+
inits.push(...subItemInits);
217+
}
218+
}
219+
}
220+
221+
return inits;
222+
}
223+
224+
async function getPrototype(procedure: Declaration): Promise<string[]> {
225+
for (const reference of procedure.references) {
226+
const docs = await parser.getDocs(reference.uri);
227+
if (docs) {
228+
const prototype = docs.procedures.some(proc => proc.name === procedure.name && proc.keyword['EXTPROC'])
229+
if (prototype) {
230+
return [];
231+
}
232+
}
233+
}
234+
235+
return [
236+
`dcl-pr ${procedure.name} ${prettyKeywords(procedure.keyword, true)} extproc('${procedure.name.toLocaleUpperCase()}');`,
237+
...procedure.subItems.map(s => ` ${s.name} ${prettyKeywords(s.keyword, true)};`),
238+
`end-pr;`
239+
];
240+
}
241+
242+
function getIncludes(detail: RpgleTypeDetail, workspaceFolder: WorkspaceFolder): string[] {
243+
const includes: string[] = [];
244+
245+
if (detail.reference) {
246+
const structPath = detail.reference.position.path;
247+
if (workspaceFolder) {
248+
const relativePath = asPosix(path.relative(workspaceFolder.uri, structPath));
249+
if (!includes.includes(relativePath)) {
250+
includes.push(`/include '${relativePath}'`); // TODO: Support members style includes
251+
}
252+
}
253+
}
254+
255+
return includes;
256+
}
257+
258+
function getAssertions(docs: Cache, detail: RpgleTypeDetail, expected: string, actual: string): string[] {
259+
const assertions: string[] = [];
260+
261+
if (detail) {
262+
if (detail.type) {
263+
const assertion = getAssertion(detail.type.name);
264+
const fieldName = actual.split(`.`).pop();
265+
if (assertion === `assert`) {
266+
assertions.push(`${assertion}(${expected} = ${actual}${fieldName ? ` : '${fieldName}'` : ``});`);
267+
} else {
268+
assertions.push(`${assertion}(${expected} : ${actual}${fieldName ? ` : '${fieldName}'` : ``});`);
269+
}
270+
} else if (detail.reference) {
271+
for (const subItem of detail.reference.subItems) {
272+
const subItemType = docs.resolveType(subItem);
273+
const subItemAssertions = subItemType ?
274+
getAssertions(docs, subItemType, `${expected}.${subItem.name}`, `${actual}.${subItem.name}`) : [];
275+
assertions.push(...subItemAssertions);
276+
}
277+
}
278+
}
279+
280+
return assertions;
281+
}
282+
283+
function getDefaultValue(type: RpgleVariableType): string {
284+
switch (type) {
285+
case `char`:
286+
case `varchar`:
287+
return `''`;
288+
case `int`:
289+
case `uns`:
290+
return `0`;
291+
case `packed`:
292+
case `zoned`:
293+
return `0.0`;
294+
case `ind`:
295+
return `*off`;
296+
case `date`:
297+
return `%date('0001-01-01' : *iso)`;
298+
case `time`:
299+
return `%time('00.00.00' : *iso)`;
300+
case `timestamp`:
301+
return `%timestamp('0001-01-01-00.00.00.000000' : *iso)`;
302+
case `pointer`:
303+
return `*null`;
304+
default:
305+
return 'unknown';
306+
}
307+
}
308+
309+
function getAssertion(type: RpgleVariableType): string {
310+
switch (type) {
311+
case `char`:
312+
case `varchar`:
313+
return `aEqual`;
314+
case `int`:
315+
case `uns`:
316+
return `iEqual`;
317+
case `packed`:
318+
case `zoned`:
319+
return `assert`;
320+
case `ind`:
321+
return `nEqual`;
322+
case `date`:
323+
return `assert`;
324+
case `time`:
325+
return `assert`;
326+
case `timestamp`:
327+
return `assert`;
328+
case `pointer`:
329+
return `assert`;
330+
default:
331+
return 'unknown';
332+
}
333+
}
334+
335+
function asPosix(inPath?: string) {
336+
return inPath ? inPath.split(path.sep).join(path.posix.sep) : ``;
77337
}
78338

79339
function lineAt(document: TextDocument, line: number): string {

extension/server/src/providers/index.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from 'vscode-languageserver-textdocument';
1212
import Parser from '../../../../language/parser';
1313

14-
type Keywords = {[key: string]: string | boolean};
14+
type Keywords = { [key: string]: string | boolean };
1515

1616
// Create a simple text document manager.
1717
export const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
@@ -24,7 +24,7 @@ export const parser = new Parser();
2424

2525
const wordMatch = /[\w\#\$@]/;
2626

27-
export function getWordRangeAtPosition(document: TextDocument, position: Position): string|undefined {
27+
export function getWordRangeAtPosition(document: TextDocument, position: Position): string | undefined {
2828
const lines = document.getText().split(`\n`); // Safe to assume \n because \r is then at end of lines
2929
const line = Math.min(lines.length - 1, Math.max(0, position.line));
3030
const lineText = lines[line];
@@ -43,12 +43,14 @@ export function getWordRangeAtPosition(document: TextDocument, position: Positio
4343
if (startChar === endChar)
4444
return undefined;
4545
else
46-
return document.getText(Range.create(line, Math.max(0, startChar), line, endChar+1)).replace(/(\r\n|\n|\r)/gm, "");
46+
return document.getText(Range.create(line, Math.max(0, startChar), line, endChar + 1)).replace(/(\r\n|\n|\r)/gm, "");
4747
}
4848

49-
export function prettyKeywords(keywords: Keywords): string {
49+
const filteredKeywords = ['QUALIFIED', 'EXPORT']; // TODO: Any other filtered keywords?
50+
51+
export function prettyKeywords(keywords: Keywords, filter: boolean = false): string {
5052
return Object.keys(keywords).map(key => {
51-
if (keywords[key] ) {
53+
if ((!filter || !filteredKeywords.includes(key)) && keywords[key]) {
5254
if (typeof keywords[key] === `boolean`) {
5355
return key.toLowerCase();
5456
}

0 commit comments

Comments
 (0)