@dannysir/js-te 0.0.2 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,10 +27,12 @@ describe('[단순 연산 테스트]', () => {
27
27
 
28
28
  ### 2. 테스트 실행
29
29
 
30
- package.json에 추가:
30
+ package.json에 추가.
31
31
 
32
+ - type을 module로 설정해주세요.
32
33
  ```json
33
34
  {
35
+ "type": "module",
34
36
  "scripts": {
35
37
  "test": "js-te"
36
38
  }
@@ -42,6 +44,11 @@ package.json에 추가:
42
44
  npm test
43
45
  ```
44
46
 
47
+ ### 예시 출력 화면
48
+
49
+ <p align='center'>
50
+ <img width="585" height="902" alt="스크린샷 2025-11-20 오후 12 22 27" src="https://github.com/user-attachments/assets/3d087a61-cc44-4f5b-8a2f-efd5f15c12b7" />
51
+ </p>
45
52
 
46
53
  # API
47
54
 
@@ -127,41 +134,40 @@ Babel을 사용해서 import 구문을 변환하여 mock 함수를 가져오도
127
134
  2. `path`를 key로 이용해 Map에 저장
128
135
  2. Babel로 코드 변환
129
136
  1. 전체 파일의 import문 확인
130
- 2. import 경로가 Map에 존재하면 mock 객체로 변환
131
- 3. import 경로가 Map에 없다면 그대로 import
137
+ 2. (0.0.3 버전 추가) import 경로를 **절대 경로**로 변환
138
+ 2. import 경로(절대 경로)가 Map에 존재하면 mock 객체로 변환
139
+ 3. import 경로(절대 경로)가 Map에 없다면 그대로 import
132
140
  3. 테스트 실행
133
141
  4. 원본 파일 복구
134
142
 
135
- ### 🚨 주의 사항 (현재 수정 )
136
-
137
- mocking 기능의 경우 현재 `path`를 기반으로 직접 변환을 하기 때문에 mocking이 필요한 함수의 경우
138
-
139
- 반드시 **절대 경로**로 표현한 후 `mock` 함수에 **절대 경로**로 등록을 해주세요.
143
+ ### `mock(모듈 절대 경로), mock객체)`
140
144
 
141
- > 만약 모듈이 사용되는 모든 위치의 path가 동일하다면 상대 경로도 정삭 작동합니다.
145
+ 모듈을 모킹합니다. import 하기 **전에** 호출해야 합니다.
142
146
 
143
- ### `mock(모듈경로, mock객체)`
147
+ **🚨 주의사항**
144
148
 
145
- 모듈을 모킹합니다. import 하기 **전에** 호출해야 합니다.
149
+ 1. 반드시 경로는 절대 경로로 입력해주세요.
150
+ - babel이 import문에서 절대 경로로 변환하여 확인을 하기 때문에 반드시 절대 경로로 등록해주세요.
151
+ 2. import문을 반드시 mocking 이후에 선언해주세요.
152
+ - mocking 전에 import를 하게 되면 mocking되기 전의 모듈을 가져오게 됩니다.
146
153
 
147
154
  ```javascript
148
155
  // random.js
149
156
  export const random = () => Math.random();
150
157
 
151
158
  // game.js
152
- import { random } from './random.js';
159
+ import { random } from './random.js'; // 자유롭게 import하면 babel에서 절대 경로로 변환하여 판단합니다.
153
160
  export const play = () => random() * 10;
154
161
 
155
162
  // game.test.js
156
- import { mock, test, expect } from 'js-te';
157
-
158
163
  test('랜덤 함수 모킹', async () => {
159
164
  // 1. 먼저 모킹
160
- mock('./random.js', {
165
+ mock('/Users/san/Js-Te/test-helper/random.js', { // 반드시 절대 경로로 등록
161
166
  random: () => 0.5
162
167
  });
163
168
 
164
169
  // 2. 그 다음 import
170
+ // 상단에 import문을 입력할 경우
165
171
  const { play } = await import('./game.js');
166
172
 
167
173
  // 3. 모킹된 값 사용
@@ -181,6 +187,82 @@ test('랜덤 함수 모킹', async () => {
181
187
 
182
188
  mock이 등록되어 있는지 확인합니다.
183
189
 
190
+ ## `each(cases)`
191
+
192
+ `cases`를 배열로 받아 순차적으로 테스트 진행
193
+
194
+ #### 🚨 주의 사항
195
+
196
+ `cases`는 반드시 `Array` 타입으로 받아야 합니다.
197
+
198
+ ### 플레이스 홀더
199
+
200
+ - %s - 문자열/숫자
201
+ - %o - 객체 (JSON.stringify)
202
+
203
+ ```jsx
204
+ test.each([
205
+ [1, 2, 3, 6],
206
+ [3, 4, 5, 12],
207
+ [10, 20, 13, 43],
208
+ [10, 12, 13, 35],
209
+ ])('[each test] - input : %s, %s, %s, %s', (a, b, c, result) => {
210
+ expect(a + b + c).toBe(result);
211
+ });
212
+
213
+ /* 출력 결과
214
+ ✓ [each test] - input : 1, 2, 3, 6
215
+ ✓ [each test] - input : 3, 4, 5, 12
216
+ ✓ [each test] - input : 10, 20, 13, 43
217
+ ✓ [each test] - input : 10, 12, 13, 35
218
+ */
219
+
220
+ test.each([
221
+ [{ name : 'dannysir', age : null}],
222
+ ])('[each test placeholder] - input : %o', (arg) => {
223
+ expect(arg.name).toBe('dannysir');
224
+ });
225
+
226
+ /* 출력 결과
227
+ ✓ [each test placeholder] - input : {"name":"dannysir","age":null}
228
+ */
229
+ ```
230
+
231
+ ## `beforeEach(함수)`
232
+
233
+ 각 테스트가 진행되기 전에 실행할 함수를 선언합니다.
234
+
235
+ 중첩된 describe에서의 `beforeEach`는 상위 describe의 `beforeEach`를 모두 실행한 후, 자신의 `beforeEach`를 실행합니다.
236
+
237
+ ```jsx
238
+ describe('카운터 테스트', () => {
239
+ let counter;
240
+
241
+ beforeEach(() => {
242
+ counter = 0;
243
+ });
244
+
245
+ test('카운터 증가', () => {
246
+ counter++;
247
+ expect(counter).toBe(1);
248
+ });
249
+
250
+ test('카운터는 0부터 시작', () => {
251
+ expect(counter).toBe(0);
252
+ });
253
+
254
+ describe('중첩된 describe', () => {
255
+ beforeEach(() => {
256
+ counter = 10;
257
+ });
258
+
259
+ test('카운터는 10', () => {
260
+ expect(counter).toBe(10);
261
+ });
262
+ });
263
+ });
264
+ ```
265
+
184
266
  ## 테스트 파일 찾기 규칙
185
267
 
186
268
  자동으로 다음 파일들을 찾아서 실행합니다:
@@ -204,8 +286,6 @@ mock이 등록되어 있는지 확인합니다.
204
286
  ### 기본 테스트
205
287
 
206
288
  ```javascript
207
- import { describe, test, expect } from 'js-te';
208
-
209
289
  describe('문자열 테스트', () => {
210
290
  test('문자열 합치기', () => {
211
291
  const result = 'hello' + ' ' + 'world';
@@ -222,10 +302,8 @@ describe('문자열 테스트', () => {
222
302
 
223
303
  ```javascript
224
304
  // mocking.test.js
225
- import { mock, test, expect } from 'js-te';
226
-
227
305
  test('[mocking] - mocking random function', async () => {
228
- mock('/src/test-helper/random.js', {
306
+ mock('/Users/san/Js-Te/test-helper/random.js', {
229
307
  random: () => 3,
230
308
  });
231
309
  const {play} = await import('../src/test-helper/game.js');
@@ -234,7 +312,7 @@ test('[mocking] - mocking random function', async () => {
234
312
 
235
313
 
236
314
  // game.js
237
- import {random} from '/src/test-helper/random.js'
315
+ import {random} from '/test-helper/random.js'
238
316
 
239
317
  export const play = () => {
240
318
  return random() * 10;
@@ -244,16 +322,6 @@ export const play = () => {
244
322
  export const random = () => Math.random();
245
323
  ```
246
324
 
247
- ## 설정
248
-
249
- `package.json`에 해당 설정을 하셔야 정상 작동합니다.
250
-
251
- ```json
252
- {
253
- "type": "module"
254
- }
255
- ```
256
-
257
325
  ## 링크
258
326
 
259
327
  - [GitHub](https://github.com/dannysir/Js-Te)
@@ -264,4 +332,4 @@ export const random = () => Math.random();
264
332
 
265
333
  ## 라이선스
266
334
 
267
- ISC
335
+ ISC
@@ -1,44 +1,53 @@
1
+ import path from 'path';
2
+ import {BABEL, MOCK} from "./constants.js";
3
+
1
4
  export const babelTransformImport = ({types: t}) => {
2
5
  return {
3
6
  visitor: {
4
7
  Program(path) {
5
8
  const importStatement = t.ImportDeclaration(
6
9
  [t.importSpecifier(
7
- t.identifier('__mockRegistry__'),
8
- t.identifier('__mockRegistry__')
10
+ t.identifier(MOCK.STORE_NAME),
11
+ t.identifier(MOCK.STORE_NAME)
9
12
  )],
10
- t.stringLiteral('@dannysir/js-te/src/mock/store.js')
13
+ t.stringLiteral(MOCK.STORE_PATH)
11
14
  );
12
15
  path.node.body.unshift(importStatement);
13
16
  },
14
17
 
15
- ImportDeclaration(path) {
16
- const source = path.node.source.value;
18
+ ImportDeclaration(nodePath, state) {
19
+ const source = nodePath.node.source.value;
17
20
 
18
- if (source === '@dannysir/js-te/src/mock/store.js') {
21
+ if (source === MOCK.STORE_PATH) {
19
22
  return;
20
23
  }
21
24
 
22
- const specifiers = path.node.specifiers;
25
+ const currentFilePath = state.filename || process.cwd();
26
+ const currentDir = path.dirname(currentFilePath);
27
+
28
+ let absolutePath;
29
+ if (source.startsWith(BABEL.PERIOD)) {
30
+ absolutePath = path.resolve(currentDir, source);
31
+ } else {
32
+ absolutePath = source;
33
+ }
34
+
35
+ const specifiers = nodePath.node.specifiers;
23
36
 
24
- // 1. 먼저 모듈을 한 번만 가져오기 (mock이면 mock, 아니면 실제)
25
- const moduleVarName = path.scope.generateUidIdentifier('module');
37
+ const moduleVarName = nodePath.scope.generateUidIdentifier(BABEL.MODULE);
26
38
 
27
- const moduleDeclaration = t.variableDeclaration('const', [
39
+ const moduleDeclaration = t.variableDeclaration(BABEL.CONST, [
28
40
  t.variableDeclarator(
29
41
  moduleVarName,
30
42
  t.conditionalExpression(
31
- // 조건: __mockRegistry__.has(source)
32
43
  t.callExpression(
33
- t.memberExpression(t.identifier('__mockRegistry__'), t.identifier('has')),
34
- [t.stringLiteral(source)]
44
+ t.memberExpression(t.identifier(MOCK.STORE_NAME), t.identifier(BABEL.HAS)),
45
+ [t.stringLiteral(absolutePath)]
35
46
  ),
36
- // true: mock 반환
37
47
  t.callExpression(
38
- t.memberExpression(t.identifier('__mockRegistry__'), t.identifier('get')),
39
- [t.stringLiteral(source)]
48
+ t.memberExpression(t.identifier(MOCK.STORE_NAME), t.identifier(BABEL.GET)),
49
+ [t.stringLiteral(absolutePath)]
40
50
  ),
41
- // false: 실제 import
42
51
  t.awaitExpression(
43
52
  t.importExpression(t.stringLiteral(source))
44
53
  )
@@ -46,7 +55,6 @@ export const babelTransformImport = ({types: t}) => {
46
55
  )
47
56
  ]);
48
57
 
49
- // 2. 각 specifier를 moduleVarName에서 추출
50
58
  const extractDeclarations = specifiers.map(spec => {
51
59
  let importedName, localName;
52
60
 
@@ -55,7 +63,6 @@ export const babelTransformImport = ({types: t}) => {
55
63
  localName = spec.local.name;
56
64
  } else if (t.isImportNamespaceSpecifier(spec)) {
57
65
  localName = spec.local.name;
58
- // namespace import는 전체 모듈
59
66
  return t.variableDeclarator(
60
67
  t.identifier(localName),
61
68
  moduleVarName
@@ -71,9 +78,9 @@ export const babelTransformImport = ({types: t}) => {
71
78
  );
72
79
  });
73
80
 
74
- const extractDeclaration = t.variableDeclaration('const', extractDeclarations);
81
+ const extractDeclaration = t.variableDeclaration(BABEL.CONST, extractDeclarations);
75
82
 
76
- path.replaceWithMultiple([moduleDeclaration, extractDeclaration]);
83
+ nodePath.replaceWithMultiple([moduleDeclaration, extractDeclaration]);
77
84
  }
78
85
  }
79
86
  };
package/bin/cli.js CHANGED
@@ -4,9 +4,9 @@ import fs from 'fs';
4
4
  import path from 'path';
5
5
  import {transformSync} from '@babel/core';
6
6
  import * as jsTe from '../index.js';
7
- import {green, yellow} from "../utils/consoleColor.js";
7
+ import {green, red, yellow} from "../utils/consoleColor.js";
8
8
  import {getTestResultMsg} from "../utils/makeMessage.js";
9
- import {RESULT_TITLE} from "../constants.js";
9
+ import {BABEL, PATH, RESULT_TITLE} from "../constants.js";
10
10
  import { babelTransformImport } from '../babelTransformImport.js';
11
11
 
12
12
  let totalPassed = 0;
@@ -25,7 +25,7 @@ const transformFile = (filePath) => {
25
25
  filename: filePath,
26
26
  plugins: [babelTransformImport],
27
27
  parserOpts: {
28
- sourceType: 'module',
28
+ sourceType: BABEL.MODULE,
29
29
  plugins: ['dynamicImport']
30
30
  }
31
31
  });
@@ -35,8 +35,13 @@ const transformFile = (filePath) => {
35
35
 
36
36
  const restoreFiles = () => {
37
37
  for (const [filePath, originalCode] of originalFiles.entries()) {
38
- fs.writeFileSync(filePath, originalCode);
38
+ try {
39
+ fs.writeFileSync(filePath, originalCode);
40
+ } catch (error) {
41
+ console.error(red(`Failed to restore ${filePath}: ${error.message}`));
42
+ }
39
43
  }
44
+ originalFiles.clear();
40
45
  };
41
46
 
42
47
  const findTestFiles = (dir) => {
@@ -46,18 +51,18 @@ const findTestFiles = (dir) => {
46
51
  const items = fs.readdirSync(directory);
47
52
  const dirName = path.basename(directory);
48
53
 
49
- const isTestDir = dirName === 'test' || inTestDir;
54
+ const isTestDir = dirName === PATH.TEST_DIRECTORY || inTestDir;
50
55
 
51
56
  for (const item of items) {
52
- if (item === 'node_modules') continue;
57
+ if (item === PATH.NODE_MODULES) continue;
53
58
 
54
59
  const fullPath = path.join(directory, item);
55
60
  const stat = fs.statSync(fullPath);
56
61
 
57
62
  if (stat.isDirectory()) {
58
63
  walk(fullPath, isTestDir);
59
- } else if (item.endsWith('.test.js') || isTestDir) {
60
- if (item.endsWith('.js')) {
64
+ } else if (item.endsWith(PATH.TEST_FILE) || isTestDir) {
65
+ if (item.endsWith(PATH.JAVASCRIPT_FILE)) {
61
66
  files.push(fullPath);
62
67
  }
63
68
  }
@@ -75,14 +80,14 @@ const findAllSourceFiles = (dir) => {
75
80
  const items = fs.readdirSync(directory);
76
81
 
77
82
  for (const item of items) {
78
- if (item === 'node_modules' || item === 'bin' || item === 'test') continue;
83
+ if (item === PATH.NODE_MODULES || item === PATH.BIN || item === PATH.TEST_DIRECTORY) continue;
79
84
 
80
85
  const fullPath = path.join(directory, item);
81
86
  const stat = fs.statSync(fullPath);
82
87
 
83
88
  if (stat.isDirectory()) {
84
89
  walk(fullPath);
85
- } else if (item.endsWith('.js') && !item.endsWith('.test.js')) {
90
+ } else if (item.endsWith(PATH.JAVASCRIPT_FILE) && !item.endsWith(PATH.TEST_FILE)) {
86
91
  files.push(fullPath);
87
92
  }
88
93
  }
@@ -92,32 +97,41 @@ const findAllSourceFiles = (dir) => {
92
97
  return files;
93
98
  }
94
99
 
95
- const sourceFiles = findAllSourceFiles(process.cwd());
96
100
 
97
- for (const file of sourceFiles) {
98
- transformFile(file);
99
- }
101
+ const main = async () => {
102
+ try {
103
+ const sourceFiles = findAllSourceFiles(process.cwd());
104
+ for (const file of sourceFiles) {
105
+ transformFile(file);
106
+ }
100
107
 
101
- const testFiles = findTestFiles(process.cwd());
108
+ const testFiles = findTestFiles(process.cwd());
102
109
 
103
- console.log(`\nFound ${green(testFiles.length)} test file(s)`);
110
+ console.log(`\nFound ${green(testFiles.length)} test file(s)`);
104
111
 
105
- for (const file of testFiles) {
106
- console.log(`\n${yellow(file)}\n`);
112
+ for (const file of testFiles) {
113
+ console.log(`\n${yellow(file)}\n`);
107
114
 
108
- transformFile(file);
115
+ transformFile(file);
116
+ await import(path.resolve(file));
109
117
 
110
- await import(path.resolve(file));
118
+ const {passed, failed} = await jsTe.run();
119
+ totalPassed += passed;
120
+ totalFailed += failed;
121
+ }
111
122
 
112
- const {passed, failed} = await jsTe.run();
113
- totalPassed += passed;
114
- totalFailed += failed;
115
- }
123
+ console.log(getTestResultMsg(RESULT_TITLE.TOTAL, totalPassed, totalFailed));
116
124
 
117
- restoreFiles();
125
+ return totalFailed > 0 ? 1 : 0;
118
126
 
119
- console.log(getTestResultMsg(RESULT_TITLE.TOTAL, totalPassed, totalFailed));
127
+ } catch (error) {
128
+ console.log(red('\n✗ Test execution failed'));
129
+ console.log(red(` Error: ${error.message}\n`));
130
+ return 1;
131
+ } finally {
132
+ restoreFiles();
133
+ }
134
+ };
120
135
 
121
- if (totalFailed > 0) {
122
- process.exit(1);
123
- }
136
+ const exitCode = await main();
137
+ process.exit(exitCode);
package/constants.js CHANGED
@@ -25,4 +25,26 @@ export const COLORS = {
25
25
  cyan: '\x1b[36m',
26
26
  gray: '\x1b[90m',
27
27
  bold: '\x1b[1m'
28
+ };
29
+
30
+ export const MOCK = {
31
+ STORE_NAME: 'mockStore',
32
+ STORE_PATH : '@dannysir/js-te/src/mock/store.js'
33
+ };
34
+
35
+ export const BABEL = {
36
+ MODULE: 'module',
37
+ CONST: 'const',
38
+ HAS: 'has',
39
+ GET: 'get',
40
+ PERIOD: '.',
41
+ };
42
+
43
+ export const PATH = {
44
+ NODE_MODULES: 'node_modules',
45
+ TEST_DIRECTORY: 'test',
46
+ TEST_FILE: '.test.js',
47
+ JAVASCRIPT_FILE: '.js',
48
+ BIN: 'bin',
49
+
28
50
  };
package/index.js CHANGED
@@ -6,15 +6,19 @@ import {
6
6
  import {Tests} from "./src/tests.js";
7
7
  import {green, red} from "./utils/consoleColor.js";
8
8
  import {getTestResultMsg} from "./utils/makeMessage.js";
9
+ import {clearAllMocks} from "./src/mock/store.js";
9
10
 
10
11
  const tests = new Tests();
11
12
 
12
13
  export { mock, clearAllMocks, unmock, isMocked } from './src/mock/store.js';
13
14
 
14
15
  export const test = (description, fn) => tests.test(description, fn);
16
+ test.each = (cases) => tests.testEach(cases);
15
17
 
16
18
  export const describe = (suiteName, fn) => tests.describe(suiteName, fn);
17
19
 
20
+ export const beforeEach = (fn) => tests.beforeEach(fn);
21
+
18
22
  export const expect = (actual) => {
19
23
  let value = actual;
20
24
 
@@ -73,10 +77,11 @@ export const run = async () => {
73
77
  const directoryString = green(CHECK) + (test.path === '' ? EMPTY : test.path + DIRECTORY_DELIMITER) + test.description
74
78
  console.log(directoryString);
75
79
  passed++;
80
+ clearAllMocks();
76
81
  } catch (error) {
77
82
  const errorDirectory = red(CROSS) + test.path + test.description
78
83
  console.log(errorDirectory);
79
- console.log(red(` ${error.message}`));
84
+ console.log(red(` Error Message : ${error.message}`));
80
85
  failed++;
81
86
  }
82
87
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dannysir/js-te",
3
- "version": "0.0.2",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "JavaScript test library",
6
6
  "main": "index.js",
package/src/mock/store.js CHANGED
@@ -1,17 +1,17 @@
1
- export const __mockRegistry__ = new Map();
1
+ export const mockStore = new Map();
2
2
 
3
3
  export function clearAllMocks() {
4
- __mockRegistry__.clear();
4
+ mockStore.clear();
5
5
  }
6
6
 
7
7
  export function mock(modulePath, mockExports) {
8
- __mockRegistry__.set(modulePath, mockExports);
8
+ mockStore.set(modulePath, mockExports);
9
9
  }
10
10
 
11
11
  export function unmock(modulePath) {
12
- __mockRegistry__.delete(modulePath);
12
+ mockStore.delete(modulePath);
13
13
  }
14
14
 
15
15
  export function isMocked(modulePath) {
16
- return __mockRegistry__.has(modulePath);
16
+ return mockStore.has(modulePath);
17
17
  }
package/src/tests.js CHANGED
@@ -3,22 +3,45 @@ import {DIRECTORY_DELIMITER} from "../constants.js";
3
3
  export class Tests {
4
4
  #tests = [];
5
5
  #testDepth = [];
6
+ #beforeEachArr = [];
6
7
 
7
8
  describe(str, fn) {
8
9
  this.#testDepth.push(str);
10
+ const prevLength = this.#beforeEachArr.length;
9
11
  fn();
12
+ this.#beforeEachArr.length = prevLength;
10
13
  this.#testDepth.pop();
11
14
  }
12
15
 
13
16
  test(description, fn) {
17
+ const beforeEachHooks = [...this.#beforeEachArr];
18
+
14
19
  const testObj = {
15
20
  description,
16
- fn,
21
+ fn: async () => {
22
+ for (const hook of beforeEachHooks) {
23
+ await hook();
24
+ }
25
+ await fn();
26
+ },
17
27
  path: this.#testDepth.join(DIRECTORY_DELIMITER),
18
28
  }
19
29
  this.#tests.push(testObj);
20
30
  }
21
31
 
32
+ testEach(cases) {
33
+ return (description, fn) => {
34
+ cases.forEach(testCase => {
35
+ const args = Array.isArray(testCase) ? testCase : [testCase];
36
+ this.test(this.#formatDescription(args, description), () => fn(...args));
37
+ });
38
+ };
39
+ }
40
+
41
+ beforeEach(fn) {
42
+ this.#beforeEachArr.push(fn);
43
+ }
44
+
22
45
  getTests() {
23
46
  return [...this.#tests];
24
47
  }
@@ -26,5 +49,24 @@ export class Tests {
26
49
  clearTests() {
27
50
  this.#tests = [];
28
51
  this.#testDepth = [];
52
+ this.#beforeEachArr = [];
53
+ }
54
+
55
+ #formatDescription(args, description) {
56
+ let argIndex = 0;
57
+ return description.replace(/%([so])/g, (match, type) => {
58
+ if (argIndex >= args.length) return match;
59
+
60
+ const arg = args[argIndex++];
61
+
62
+ switch (type) {
63
+ case 's':
64
+ return arg;
65
+ case 'o':
66
+ return JSON.stringify(arg);
67
+ default:
68
+ return match;
69
+ }
70
+ });
29
71
  }
30
72
  }