monocart-coverage-reports 2.10.4 → 2.10.6

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
@@ -967,6 +967,8 @@ process.env.MCR_LOG_TIME = true
967
967
  - Coverage for Next.js, both server side and client side:
968
968
  - [nextjs-with-playwright](https://github.com/cenfun/nextjs-with-playwright)
969
969
  - [nextjs-with-playwright-istanbul](https://github.com/cenfun/nextjs-with-playwright-istanbul)
970
+ - Coverage for Remix:
971
+ - [remix-with-playwright](https://github.com/cenfun/remix-with-playwright)
970
972
  - see [Collecting V8 Coverage Data with Playwright](#collecting-v8-coverage-data-with-playwright)
971
973
 
972
974
  ### [c8](https://github.com/bcoe/c8)
package/README.zh-Hans.md CHANGED
@@ -971,6 +971,8 @@ process.env.MCR_LOG_TIME = true
971
971
  - Coverage for Next.js, both server side and client side:
972
972
  - [nextjs-with-playwright](https://github.com/cenfun/nextjs-with-playwright)
973
973
  - [nextjs-with-playwright-istanbul](https://github.com/cenfun/nextjs-with-playwright-istanbul)
974
+ - Coverage for Remix:
975
+ - [remix-with-playwright](https://github.com/cenfun/remix-with-playwright)
974
976
  - see [Collecting V8 Coverage Data with Playwright](#collecting-v8-coverage-data-with-playwright)
975
977
 
976
978
  ### [c8](https://github.com/bcoe/c8)
@@ -13,6 +13,8 @@ const findOriginalRange = require('./find-original-range.js');
13
13
  const { getJsAstInfo, getCssAstInfo } = require('./ast.js');
14
14
  const { getIgnoredRanges } = require('./ignore.js');
15
15
 
16
+ const { getUntestedList } = require('./untested.js');
17
+
16
18
  const {
17
19
  sortRanges, dedupeCountRanges, mergeRangesWith
18
20
  } = require('../utils/dedupe.js');
@@ -1333,66 +1335,215 @@ const initJsCoverageList = (item) => {
1333
1335
  return coverageList;
1334
1336
  };
1335
1337
 
1336
- // ========================================================================================================
1337
-
1338
- const isCoveredInRanges = (range, uncoveredBytes) => {
1339
- if (!uncoveredBytes.length) {
1340
- return true;
1338
+ const logConvertTime = (msg, time_start, untested) => {
1339
+ if (untested) {
1340
+ return;
1341
1341
  }
1342
- for (const item of uncoveredBytes) {
1343
- if (range.start >= item.start && range.end <= item.end) {
1344
- return false;
1342
+ Util.logTime(msg, time_start);
1343
+ };
1344
+
1345
+ const convertCoverages = (list, options, untested) => {
1346
+ const stateList = [];
1347
+
1348
+ for (const item of list) {
1349
+ // console.log([item.id]);
1350
+
1351
+ const time_start_ast = Date.now();
1352
+
1353
+ const {
1354
+ type, source, fake, sourcePath
1355
+ } = item;
1356
+
1357
+ // for source file, type could be ts or vue as extname, but js = true
1358
+ const js = type === 'js';
1359
+ item.js = js;
1360
+
1361
+ // source mapping
1362
+ const locator = new Locator(source);
1363
+ const maxContentLength = source.length;
1364
+
1365
+ // ============================
1366
+ // move sourceMap
1367
+ const sourceMap = item.sourceMap;
1368
+ if (sourceMap) {
1369
+ delete item.sourceMap;
1370
+ }
1371
+
1372
+ // ============================
1373
+ // move functions and ranges to coverageList
1374
+ let coverageList = [];
1375
+ let astInfo;
1376
+ if (js) {
1377
+ coverageList = initJsCoverageList(item);
1378
+ // remove original functions
1379
+ if (!Util.isDebug()) {
1380
+ delete item.functions;
1381
+ }
1382
+ astInfo = getJsAstInfo(item, coverageList);
1383
+ } else {
1384
+ // convent css covered ranges to rules ranges and include uncovered ranges
1385
+ astInfo = getCssAstInfo(item, coverageList);
1386
+ // remove original ranges
1387
+ if (!Util.isDebug()) {
1388
+ delete item.ranges;
1389
+ }
1345
1390
  }
1391
+
1392
+ logConvertTime(` ┌ [convert] parsed ast: ${sourcePath} (${EC.cyan(Util.BSF(maxContentLength))})`, time_start_ast, untested);
1393
+
1394
+ // console.log(sourcePath, astInfo.statements.length);
1395
+ // ============================
1396
+
1397
+ const time_start_unpack = Date.now();
1398
+
1399
+ // current file and it's sources from sourceMap
1400
+ // see const originalState
1401
+ const state = {
1402
+ js,
1403
+ type,
1404
+ source,
1405
+ fake,
1406
+ sourcePath,
1407
+ sourceMap,
1408
+ locator,
1409
+ maxContentLength,
1410
+ decodedMappings: [],
1411
+ rangeCache: new Map(),
1412
+ diffCache: new Map(),
1413
+
1414
+ // alignTextList: [],
1415
+
1416
+ // coverage info
1417
+ bytes: [],
1418
+ functions: [],
1419
+ branches: [],
1420
+ statements: [],
1421
+ astInfo,
1422
+ // for sub source files
1423
+ coverageList,
1424
+ originalList: [],
1425
+ // coverage data
1426
+ v8Data: item
1427
+ };
1428
+
1429
+ unpackDistFile(item, state, options);
1430
+ const unpackedFiles = EC.cyan(`${state.originalList.length} files`);
1431
+
1432
+ stateList.push(state);
1433
+
1434
+ logConvertTime(` ┌ [convert] unpacked sourcemap: ${sourcePath} (${unpackedFiles})`, time_start_unpack, untested);
1435
+
1346
1436
  }
1347
- return true;
1437
+
1438
+ return stateList;
1439
+ };
1440
+
1441
+ // ========================================================================================================
1442
+
1443
+ const isUncoveredRange = (range, key, uncoveredGroups) => {
1444
+
1445
+ let uncovered = true;
1446
+ uncoveredGroups.forEach((group) => {
1447
+ const { groupMap, state } = group;
1448
+
1449
+ // ========================================================
1450
+ // self key
1451
+ if (groupMap.has(key)) {
1452
+ return;
1453
+ }
1454
+
1455
+ // ========================================================
1456
+ // in all group
1457
+ for (const item of groupMap.values()) {
1458
+ if (range.start >= item.start && range.end <= item.end) {
1459
+ return;
1460
+ }
1461
+ }
1462
+
1463
+ // ========================================================
1464
+ // no sourcemap mappings in range
1465
+ // check original range in decodedMappings
1466
+ const { decodedMappings } = state;
1467
+ const item = decodedMappings.find((it) => it.originalOffset >= range.start && it.originalOffset <= range.end);
1468
+ if (!item) {
1469
+ return;
1470
+ }
1471
+
1472
+ // ========================================================
1473
+ uncovered = false;
1474
+
1475
+ });
1476
+
1477
+ return uncovered;
1478
+
1348
1479
  };
1349
1480
 
1350
1481
  const mergeV8Data = (state, stateList) => {
1351
1482
  // console.log(stateList);
1352
1483
 
1353
- // const sourcePath = state.sourcePath;
1354
- // console.log(sourcePath);
1355
- // if (sourcePath.endsWith('scroll_zoom.ts')) {
1356
- // console.log('merge v8 data ===================================', sourcePath);
1357
- // console.log(stateList.map((it) => it.bytes));
1358
- // console.log(stateList.map((it) => it.functions));
1359
- // console.log(stateList.map((it) => it.branches.map((b) => [`${b.start}-${b.end}`, JSON.stringify(b.locations.map((l) => l.count))])));
1484
+ // let debug = false;
1485
+ // if (state.sourcePath.endsWith('counter.tsx')) {
1486
+ // debug = true;
1487
+ // console.log('merge v8 data ===================================', state.sourcePath);
1488
+ // console.log('bytes before ============', stateList.map((it) => it.bytes));
1489
+ // // console.log('statements ==============', stateList.map((it) => it.statements));
1490
+ // // console.log('functions ==============', stateList.map((it) => it.functions));
1491
+ // // console.log('branches ======================', stateList.map((it) => it.branches.map((b) => [`${b.start}-${b.end}`, JSON.stringify(b.locations.map((l) => l.count))])));
1360
1492
  // }
1361
1493
 
1362
1494
  // ===========================================================
1363
1495
  // bytes
1364
1496
  const mergedBytes = [];
1365
- const uncoveredList = [];
1497
+
1498
+ const coveredMap = new Map();
1499
+ const uncoveredMap = new Map();
1500
+ const uncoveredGroups = [];
1366
1501
  stateList.forEach((st) => {
1502
+ const groupMap = new Map();
1367
1503
  const bytes = dedupeCountRanges(st.bytes);
1368
- const uncoveredBytes = [];
1369
1504
  bytes.forEach((range) => {
1505
+ const key = `${range.start}_${range.end}`;
1370
1506
  if (range.count) {
1371
1507
  mergedBytes.push(range);
1508
+ coveredMap.set(key, true);
1372
1509
  } else {
1373
- uncoveredBytes.push(range);
1510
+ uncoveredMap.set(key, range);
1511
+ groupMap.set(key, range);
1374
1512
  }
1375
1513
  });
1376
- uncoveredList.push(uncoveredBytes);
1514
+
1515
+ uncoveredGroups.push({
1516
+ groupMap,
1517
+ state: st
1518
+ });
1377
1519
  });
1378
- // just remove uncovered range
1379
- uncoveredList.forEach((currentBytes) => {
1380
- currentBytes.forEach((range) => {
1381
- for (const targetBytes of uncoveredList) {
1382
- if (targetBytes === currentBytes) {
1383
- continue;
1384
- }
1385
- if (isCoveredInRanges(range, targetBytes)) {
1386
- return;
1387
- }
1388
- }
1520
+
1521
+ // if (debug) {
1522
+ // console.log('coveredMap ============', coveredMap);
1523
+ // console.log('uncoveredMap ============', uncoveredMap);
1524
+ // console.log('uncoveredGroups ============', uncoveredGroups);
1525
+ // }
1526
+
1527
+ uncoveredMap.forEach((range, key) => {
1528
+
1529
+ // in covered range
1530
+ if (coveredMap.has(key)) {
1531
+ return;
1532
+ }
1533
+
1534
+ if (isUncoveredRange(range, key, uncoveredGroups)) {
1389
1535
  mergedBytes.push(range);
1390
- });
1536
+ }
1537
+
1391
1538
  });
1392
1539
 
1393
1540
  // will be dedupeCountRanges in collectFileCoverage
1394
1541
  state.bytes = mergedBytes;
1395
1542
 
1543
+ // if (debug) {
1544
+ // console.log('bytes after ============', mergedBytes);
1545
+ // }
1546
+
1396
1547
  // ===========================================================
1397
1548
  // functions
1398
1549
  const allFunctions = stateList.map((it) => it.functions).flat();
@@ -1432,6 +1583,20 @@ const mergeV8Data = (state, stateList) => {
1432
1583
  const mergedFunctions = mergeRangesWith(allFunctions, functionComparer, functionHandler);
1433
1584
  state.functions = mergedFunctions;
1434
1585
 
1586
+ // ===========================================================
1587
+ // statements
1588
+ const allStatements = stateList.map((it) => it.statements).flat();
1589
+ const statementComparer = (lastRange, range) => {
1590
+ // exact matched because the statement range is generated from ast
1591
+ return lastRange.start === range.start && lastRange.end === range.end;
1592
+ };
1593
+ const statementHandler = (lastRange, range) => {
1594
+ // merge statements count
1595
+ lastRange.count += range.count;
1596
+ };
1597
+ const mergedStatements = mergeRangesWith(allStatements, statementComparer, statementHandler);
1598
+ state.statements = mergedStatements;
1599
+
1435
1600
  // ===========================================================
1436
1601
  // branches
1437
1602
  const allBranches = stateList.map((it) => it.branches).flat();
@@ -1458,6 +1623,43 @@ const mergeV8Data = (state, stateList) => {
1458
1623
  // }
1459
1624
 
1460
1625
 
1626
+ };
1627
+
1628
+ const addUntestedFiles = async (stateList, options) => {
1629
+
1630
+ const time_start_untested = Date.now();
1631
+
1632
+ const testedMap = new Map();
1633
+ stateList.forEach((state) => {
1634
+ const { v8Data, originalList } = state;
1635
+ // dedupe dist file if not debug
1636
+ if (!v8Data.dedupe) {
1637
+ testedMap.set(state.sourcePath, true);
1638
+ }
1639
+ originalList.forEach((originalState) => {
1640
+ testedMap.set(originalState.sourcePath, true);
1641
+ });
1642
+ });
1643
+
1644
+ // console.log('testedMap', testedMap);
1645
+
1646
+ const untestedList = await getUntestedList(testedMap, options, 'v8');
1647
+ if (!untestedList) {
1648
+ return;
1649
+ }
1650
+
1651
+ const untestedStateList = convertCoverages(untestedList, options, true);
1652
+
1653
+ // console.log(untestedStateList);
1654
+ untestedStateList.forEach((state) => {
1655
+ stateList.push(state);
1656
+ });
1657
+
1658
+ // console.log('untestedList', untestedList);
1659
+
1660
+ Util.logTime(` ┌ [convert] added untested files: ${EC.yellow(untestedList.length)}`, time_start_untested);
1661
+
1662
+
1461
1663
  };
1462
1664
 
1463
1665
  const generateV8DataList = (stateList, options) => {
@@ -1536,109 +1738,17 @@ const generateV8DataList = (stateList, options) => {
1536
1738
  };
1537
1739
  };
1538
1740
 
1539
- const convertV8List = (v8list, options) => {
1540
-
1541
- const stateList = [];
1542
-
1543
- for (const item of v8list) {
1544
- // console.log([item.id]);
1545
-
1546
- const time_start_ast = Date.now();
1547
-
1548
- const {
1549
- type, source, fake, sourcePath
1550
- } = item;
1551
-
1552
- // for source file, type could be ts or vue as extname, but js = true
1553
- const js = type === 'js';
1554
- item.js = js;
1741
+ const convertV8List = async (v8list, options) => {
1555
1742
 
1556
- // source mapping
1557
- const locator = new Locator(source);
1558
- const maxContentLength = source.length;
1743
+ // for tested files
1744
+ const stateList = convertCoverages(v8list, options);
1559
1745
 
1560
- // ============================
1561
- // move sourceMap
1562
- const sourceMap = item.sourceMap;
1563
- if (sourceMap) {
1564
- delete item.sourceMap;
1565
- }
1566
-
1567
- // ============================
1568
- // move functions and ranges to coverageList
1569
- let coverageList = [];
1570
- let astInfo;
1571
- if (js) {
1572
- coverageList = initJsCoverageList(item);
1573
- // remove original functions
1574
- if (!Util.isDebug()) {
1575
- delete item.functions;
1576
- }
1577
- astInfo = getJsAstInfo(item, coverageList);
1578
- } else {
1579
- // convent css covered ranges to rules ranges and include uncovered ranges
1580
- astInfo = getCssAstInfo(item, coverageList);
1581
- // remove original ranges
1582
- if (!Util.isDebug()) {
1583
- delete item.ranges;
1584
- }
1585
- }
1586
-
1587
- Util.logTime(` ┌ [convert] parsed ast: ${sourcePath} (${EC.cyan(Util.BSF(maxContentLength))})`, time_start_ast);
1588
-
1589
- // console.log(sourcePath, astInfo.statements.length);
1590
- // ============================
1591
-
1592
- const time_start_unpack = Date.now();
1593
-
1594
- // current file and it's sources from sourceMap
1595
- // see const originalState
1596
- const state = {
1597
- js,
1598
- type,
1599
- source,
1600
- fake,
1601
- sourcePath,
1602
- sourceMap,
1603
- locator,
1604
- maxContentLength,
1605
- decodedMappings: [],
1606
- rangeCache: new Map(),
1607
- diffCache: new Map(),
1608
-
1609
- // alignTextList: [],
1610
-
1611
- // coverage info
1612
- bytes: [],
1613
- functions: [],
1614
- branches: [],
1615
- statements: [],
1616
- astInfo,
1617
- // for sub source files
1618
- coverageList,
1619
- originalList: [],
1620
- // coverage data
1621
- v8Data: item
1622
- };
1623
-
1624
- unpackDistFile(item, state, options);
1625
- const unpackedFiles = EC.cyan(`${state.originalList.length} files`);
1626
-
1627
- stateList.push(state);
1628
-
1629
- Util.logTime(` ┌ [convert] unpacked sourcemap: ${sourcePath} (${unpackedFiles})`, time_start_unpack);
1630
-
1631
- }
1746
+ // empty coverage handler
1747
+ await addUntestedFiles(stateList, options);
1632
1748
 
1633
1749
  const time_start_convert = Date.now();
1634
-
1635
1750
  const dataList = generateV8DataList(stateList, options);
1636
1751
  const dataFiles = EC.cyan(`${dataList.v8DataList.length} files`);
1637
-
1638
- // stateList.forEach((st, i) => {
1639
- // Util.writeFileSync(`align-text-${i}.js`, `window.gridData = ${JSON.stringify(st.alignTextList, null, 4)}`);
1640
- // });
1641
-
1642
1752
  Util.logTime(` ┌ [convert] converted data list (${dataFiles})`, time_start_convert);
1643
1753
 
1644
1754
  return dataList;
@@ -0,0 +1,237 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const Util = require('../utils/util.js');
4
+
5
+ const { pathToFileURL, fileURLToPath } = require('url');
6
+
7
+ // const EC = require('eight-colors');
8
+ const { minimatch } = require('../packages/monocart-coverage-vendor.js');
9
+
10
+ // ========================================================================================================
11
+
12
+ const resolveAllDirList = (input) => {
13
+ const dirs = Util.toList(input, ',');
14
+ if (!dirs.length) {
15
+ return;
16
+ }
17
+ const dirList = dirs.filter((it) => fs.existsSync(it));
18
+ if (!dirList.length) {
19
+ return;
20
+ }
21
+ return dirList;
22
+ };
23
+
24
+ const resolveAllFilter = (input) => {
25
+ // for function handler
26
+ if (typeof input === 'function') {
27
+ return input;
28
+ }
29
+
30
+ // for single minimatch pattern
31
+ if (input && typeof input === 'string') {
32
+ // string to multiple patterns "{...}"
33
+ const obj = Util.strToObj(input);
34
+ if (obj) {
35
+ input = obj;
36
+ } else {
37
+ return (filePath) => {
38
+ return minimatch(filePath, input);
39
+ };
40
+ }
41
+ }
42
+
43
+ // for patterns
44
+ if (input && typeof input === 'object') {
45
+ const patterns = Object.keys(input);
46
+ return (filePath) => {
47
+ for (const pattern of patterns) {
48
+ if (minimatch(filePath, pattern)) {
49
+ return input[pattern];
50
+ }
51
+ }
52
+ // false if not matched
53
+ };
54
+ }
55
+
56
+ // default
57
+ return () => true;
58
+ };
59
+
60
+ const resolveAllOptions = (input) => {
61
+ if (!input) {
62
+ return;
63
+ }
64
+
65
+ let dir;
66
+ let filter;
67
+ let transformer;
68
+
69
+ if (typeof input === 'string') {
70
+ const obj = Util.strToObj(input);
71
+ if (obj) {
72
+ dir = obj.dir;
73
+ filter = obj.filter;
74
+ transformer = obj.transformer;
75
+ } else {
76
+ dir = input;
77
+ }
78
+ } else if (Array.isArray(input)) {
79
+ dir = input;
80
+ } else {
81
+ dir = input.dir;
82
+ filter = input.filter;
83
+ transformer = input.transformer;
84
+ }
85
+
86
+ const dirList = resolveAllDirList(dir);
87
+ if (!dirList) {
88
+ return;
89
+ }
90
+
91
+ const fileFilter = resolveAllFilter(filter);
92
+ const fileTransformer = typeof transformer === 'function' ? transformer : () => {};
93
+
94
+ return {
95
+ dirList,
96
+ fileFilter,
97
+ fileTransformer
98
+ };
99
+ };
100
+
101
+ const resolveFileType = (fileType, filePath) => {
102
+ if (fileType === 'js' || fileType === 'css') {
103
+ return fileType;
104
+ }
105
+ const extname = path.extname(filePath);
106
+ if (['.css', '.scss', '.sass', '.less'].includes(extname)) {
107
+ return 'css';
108
+ }
109
+ return 'js';
110
+ };
111
+
112
+ const getUntestedCoverageData = (emptyList, options, coverageType) => {
113
+
114
+ // NOTE: do not save untested file to raw report
115
+
116
+ const emptyCoverageList = [];
117
+
118
+ // save all empty source and sourcemap
119
+ for (const entryFile of emptyList) {
120
+
121
+ const { type, url } = entryFile;
122
+
123
+ if (coverageType === 'istanbul') {
124
+
125
+ // ===============================================
126
+ const item = {
127
+ path: fileURLToPath(url),
128
+ statementMap: {},
129
+ fnMap: {},
130
+ branchMap: {},
131
+ s: {},
132
+ f: {},
133
+ b: {}
134
+ };
135
+ // object
136
+ emptyCoverageList.push(item);
137
+
138
+ } else {
139
+
140
+ // ===============================================
141
+ if (type === 'js') {
142
+ // empty js
143
+ entryFile.functions = entryFile.functions || [{
144
+ functionName: '',
145
+ ranges: [{
146
+ startOffset: 0,
147
+ endOffset: entryFile.source.length,
148
+ count: 0
149
+ }]
150
+ }];
151
+ } else {
152
+ // empty css
153
+ entryFile.ranges = entryFile.ranges || [];
154
+ }
155
+
156
+ // will be parsed to AST and converted to V8 coverage
157
+ emptyCoverageList.push(entryFile);
158
+
159
+ }
160
+
161
+ }
162
+
163
+ return emptyCoverageList;
164
+ };
165
+
166
+ const getEmptyCoverages = async (fileList, options, coverageType, fileTransformer) => {
167
+
168
+ const emptyList = [];
169
+
170
+ for (const item of fileList) {
171
+ const {
172
+ filePath, fileType, sourcePath
173
+ } = item;
174
+ const type = resolveFileType(fileType, filePath);
175
+ const url = pathToFileURL(filePath).toString();
176
+ const source = Util.readFileSync(filePath);
177
+ const entryFile = {
178
+ empty: true,
179
+ type,
180
+ url,
181
+ sourcePath,
182
+ // for empty, css supports both source and text
183
+ source
184
+ };
185
+
186
+ await fileTransformer(entryFile, coverageType);
187
+
188
+ // after transformer
189
+ entryFile.id = Util.calculateSha1(entryFile.sourcePath + entryFile.source);
190
+
191
+ emptyList.push(entryFile);
192
+ }
193
+
194
+ // save empty coverage for merging raw reports
195
+ return getUntestedCoverageData(emptyList, options, coverageType);
196
+ };
197
+
198
+ const getUntestedList = (testedMap, options, coverageType = 'v8') => {
199
+ const allOptions = resolveAllOptions(options.all);
200
+ // console.log('allOptions', all, allOptions);
201
+ if (!allOptions) {
202
+ return;
203
+ }
204
+ const {
205
+ dirList, fileFilter, fileTransformer
206
+ } = allOptions;
207
+ const fileList = [];
208
+ dirList.forEach((dir) => {
209
+ Util.forEachFile(dir, [], (fileName, fileDir) => {
210
+ const filePath = path.resolve(fileDir, fileName);
211
+ // return file extname for file type
212
+ const fileType = fileFilter(filePath);
213
+ if (fileType) {
214
+ const sourcePath = Util.relativePath(filePath);
215
+ if (!testedMap.has(sourcePath)) {
216
+ fileList.push({
217
+ filePath,
218
+ fileType,
219
+ sourcePath
220
+ });
221
+ }
222
+ }
223
+ });
224
+ });
225
+ // console.log('fileList', fileList);
226
+ if (!fileList.length) {
227
+ return;
228
+ }
229
+
230
+ return getEmptyCoverages(fileList, options, coverageType, fileTransformer);
231
+
232
+ };
233
+
234
+
235
+ module.exports = {
236
+ getUntestedList
237
+ };
@@ -67,7 +67,8 @@ module.exports = {
67
67
  // options for adding empty coverage for all files
68
68
  // all: {
69
69
  // dir: ['src'],
70
- // filter: (sourcePath) => true
70
+ // filter: (sourcePath) => true,
71
+ // transformer: (entry) => {}
71
72
  // },
72
73
  all: null,
73
74