Skip to content

Commit 7ef0c14

Browse files
committed
feat(utils): aggregate coverage per folder
1 parent 211be5b commit 7ef0c14

File tree

2 files changed

+195
-33
lines changed

2 files changed

+195
-33
lines changed

packages/utils/src/lib/coverage-tree.ts

Lines changed: 83 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,25 @@ import type {
66
import { splitFilePath } from './file-system.js';
77
import { formatGitPath } from './git/git.js';
88

9-
type FileCoverage = {
9+
export type FileCoverage = {
1010
path: string;
1111
total: number;
12-
hits: number;
12+
covered: number;
1313
missing: CoverageTreeMissingLOC[];
1414
};
1515

16-
// TODO: calculate folder coverage
17-
const COVERAGE_PLACEHOLDER = -1;
16+
type CoverageStats = Pick<FileCoverage, 'covered' | 'total'>;
17+
18+
type FileTree = FolderNode | FileNode;
19+
20+
type FileNode = FileCoverage & {
21+
name: string;
22+
};
23+
24+
type FolderNode = {
25+
name: string;
26+
children: FileTree[];
27+
};
1828

1929
export function filesCoverageToTree(
2030
files: FileCoverage[],
@@ -26,14 +36,16 @@ export function filesCoverageToTree(
2636
path: formatGitPath(file.path, gitRoot),
2737
}));
2838

29-
const root = normalizedFiles.reduce<CoverageTreeNode>(
30-
(acc: CoverageTreeNode, { path: filePath, ...coverage }) => {
31-
const { folders, file } = splitFilePath(filePath);
39+
const tree = normalizedFiles.reduce<FileTree>(
40+
(acc, coverage) => {
41+
const { folders, file } = splitFilePath(coverage.path);
3242
return addNode(acc, folders, file, coverage);
3343
},
34-
{ name: '.', values: { coverage: COVERAGE_PLACEHOLDER } },
44+
{ name: '.', children: [] },
3545
);
3646

47+
const root = calculateTreeCoverage(tree);
48+
3749
return {
3850
type: 'coverage',
3951
...(title && { title }),
@@ -42,18 +54,19 @@ export function filesCoverageToTree(
4254
}
4355

4456
function addNode(
45-
root: CoverageTreeNode,
57+
root: FileTree,
4658
folders: string[],
4759
file: string,
48-
coverage: Omit<FileCoverage, 'path'>,
49-
): CoverageTreeNode {
60+
coverage: FileCoverage,
61+
): FileTree {
5062
const folder = folders[0];
63+
const rootChildren = 'children' in root ? root.children : [];
5164

5265
if (folder) {
53-
if (root.children?.some(({ name }) => name === folder)) {
66+
if (rootChildren.some(({ name }) => name === folder)) {
5467
return {
5568
...root,
56-
children: root.children.map(node =>
69+
children: rootChildren.map(node =>
5770
node.name === folder
5871
? addNode(node, folders.slice(1), file, coverage)
5972
: node,
@@ -63,9 +76,9 @@ function addNode(
6376
return {
6477
...root,
6578
children: [
66-
...(root.children ?? []),
79+
...rootChildren,
6780
addNode(
68-
{ name: folder, values: { coverage: COVERAGE_PLACEHOLDER } },
81+
{ name: folder, children: [] },
6982
folders.slice(1),
7083
file,
7184
coverage,
@@ -76,25 +89,65 @@ function addNode(
7689

7790
return {
7891
...root,
79-
children: [
80-
...(root.children ?? []),
81-
{
82-
name: file,
83-
values: {
84-
coverage: calculateCoverage(coverage),
85-
missing: coverage.missing,
86-
},
87-
},
88-
],
92+
children: [...rootChildren, { ...coverage, name: file }],
8993
};
9094
}
9195

92-
function calculateCoverage({
93-
hits,
94-
total,
95-
}: Pick<FileCoverage, 'hits' | 'total'>): number {
96+
function calculateTreeCoverage(root: FileTree): CoverageTreeNode {
97+
if ('children' in root) {
98+
const stats = aggregateChildCoverage(root.children);
99+
const coverage = calculateCoverage(stats);
100+
return {
101+
name: root.name,
102+
values: { coverage },
103+
children: root.children.map(calculateTreeCoverage),
104+
};
105+
}
106+
107+
return {
108+
name: root.name,
109+
values: {
110+
coverage: calculateCoverage(root),
111+
missing: root.missing,
112+
},
113+
};
114+
}
115+
116+
function calculateCoverage({ covered, total }: CoverageStats): number {
96117
if (total === 0) {
97118
return 1;
98119
}
99-
return hits / total;
120+
return covered / total;
121+
}
122+
123+
function aggregateChildCoverage(
124+
nodes: FileTree[],
125+
cache = new Map<FolderNode, CoverageStats>(),
126+
): CoverageStats {
127+
return nodes.reduce<CoverageStats>(
128+
(acc, node) => {
129+
const stats = getNodeCoverageStats(node, cache);
130+
return {
131+
covered: acc.covered + stats.covered,
132+
total: acc.total + stats.total,
133+
};
134+
},
135+
{ covered: 0, total: 0 },
136+
);
137+
}
138+
139+
function getNodeCoverageStats(
140+
node: FileTree,
141+
cache: Map<FolderNode, CoverageStats>,
142+
): CoverageStats {
143+
if (!('children' in node)) {
144+
return node;
145+
}
146+
const cached = cache.get(node);
147+
if (cached) {
148+
return cached;
149+
}
150+
const stats = aggregateChildCoverage(node.children, cache);
151+
cache.set(node, stats);
152+
return stats;
100153
}

packages/utils/src/lib/coverage-tree.unit.test.ts

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import path from 'node:path';
2-
import { filesCoverageToTree } from './coverage-tree.js';
2+
import type { CoverageTree } from '@code-pushup/models';
3+
import { type FileCoverage, filesCoverageToTree } from './coverage-tree.js';
34

45
describe('filesCoverageToTree', () => {
56
it('should convert list of files to folder structure', () => {
6-
const mockCoverage = { hits: 0, total: 0, missing: [] };
7-
const files = [
7+
const mockCoverage: Omit<FileCoverage, 'path'> = {
8+
covered: 0,
9+
total: 0,
10+
missing: [],
11+
};
12+
const files: FileCoverage[] = [
813
{
914
...mockCoverage,
1015
path: path.join(process.cwd(), 'src', 'components', 'CreateTodo.jsx'),
@@ -55,4 +60,108 @@ describe('filesCoverageToTree', () => {
5560
}),
5661
);
5762
});
63+
64+
it('should calculate files and folders coverage', () => {
65+
const files: FileCoverage[] = [
66+
{
67+
path: path.join(process.cwd(), 'src', 'components', 'CreateTodo.jsx'),
68+
covered: 25,
69+
total: 25,
70+
missing: [],
71+
},
72+
{
73+
path: path.join(process.cwd(), 'src', 'components', 'TodoFilter.jsx'),
74+
covered: 40,
75+
total: 50,
76+
missing: [{ startLine: 11, endLine: 21 }],
77+
},
78+
{
79+
path: path.join(process.cwd(), 'src', 'components', 'TodoList.jsx'),
80+
covered: 25,
81+
total: 25,
82+
missing: [],
83+
},
84+
{
85+
path: path.join(process.cwd(), 'src', 'hooks', 'useTodos.js'),
86+
covered: 0,
87+
total: 60,
88+
missing: [{ startLine: 1, endLine: 60 }],
89+
},
90+
{
91+
path: path.join(process.cwd(), 'src', 'App.jsx'),
92+
covered: 0,
93+
total: 20,
94+
missing: [{ startLine: 1, endLine: 20 }],
95+
},
96+
];
97+
98+
expect(filesCoverageToTree(files, process.cwd())).toEqual<CoverageTree>({
99+
type: 'coverage',
100+
root: {
101+
name: '.',
102+
values: { coverage: 0.5 }, // 90 out of 180
103+
children: [
104+
{
105+
name: 'src',
106+
values: { coverage: 0.5 }, // 90 out of 180
107+
children: [
108+
{
109+
name: 'components',
110+
values: { coverage: 0.9 }, // 90 out of 100
111+
children: [
112+
{
113+
name: 'CreateTodo.jsx',
114+
values: {
115+
coverage: 1, // 25 out of 25
116+
missing: [],
117+
},
118+
},
119+
{
120+
name: 'TodoFilter.jsx',
121+
values: {
122+
coverage: 0.8, // 40 out of 50
123+
missing: [{ startLine: 11, endLine: 21 }],
124+
},
125+
},
126+
{
127+
name: 'TodoList.jsx',
128+
values: {
129+
coverage: 1, // 25 out of 25
130+
missing: [],
131+
},
132+
},
133+
],
134+
},
135+
{
136+
name: 'hooks',
137+
values: { coverage: 0 }, // 0 out of 60
138+
children: [
139+
{
140+
name: 'useTodos.js',
141+
values: {
142+
coverage: 0, // 0 out of 60
143+
missing: [{ startLine: 1, endLine: 60 }],
144+
},
145+
},
146+
],
147+
},
148+
{
149+
name: 'App.jsx',
150+
values: {
151+
coverage: 0, // 0 out of 20
152+
missing: [{ startLine: 1, endLine: 20 }],
153+
},
154+
},
155+
],
156+
},
157+
],
158+
},
159+
});
160+
});
161+
162+
it('should include title if provided', () => {
163+
expect(filesCoverageToTree([], process.cwd(), 'Branch coverage')).toEqual(
164+
expect.objectContaining({ title: 'Branch coverage' }),
165+
);
166+
});
58167
});

0 commit comments

Comments
 (0)