Skip to content

Commit

Permalink
feat(utils): add helper functions for diffing
Browse files Browse the repository at this point in the history
  • Loading branch information
matejchalk committed Mar 11, 2024
1 parent 8cc73c3 commit 4e87cd5
Show file tree
Hide file tree
Showing 5 changed files with 277 additions and 1 deletion.
11 changes: 10 additions & 1 deletion packages/utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { exists } from '@code-pushup/models';
export { Diff, matchArrayItemsByKey, comparePairs } from './lib/diff';
export {
ProcessConfig,
ProcessError,
Expand Down Expand Up @@ -54,11 +55,19 @@ export {
README_LINK,
TERMINAL_WIDTH,
} from './lib/reports/constants';
export {
listAuditsFromAllPlugins,
listGroupsFromAllPlugins,
} from './lib/reports/flatten-plugins';
export { generateMdReport } from './lib/reports/generate-md-report';
export { generateStdoutSummary } from './lib/reports/generate-stdout-summary';
export { scoreReport } from './lib/reports/scoring';
export { sortReport } from './lib/reports/sorting';
export { ScoredReport } from './lib/reports/types';
export {
ScoredCategoryConfig,
ScoredGroup,
ScoredReport,
} from './lib/reports/types';
export {
calcDuration,
compareIssueSeverity,
Expand Down
59 changes: 59 additions & 0 deletions packages/utils/src/lib/diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
export type Diff<T> = {
before: T;
after: T;
};

export function matchArrayItemsByKey<T>({
before,
after,
key,
}: Diff<T[]> & { key: keyof T | ((item: T) => unknown) }) {
const pairs: Diff<T>[] = [];
const added: T[] = [];

const afterKeys = new Set<unknown>();
const keyFn = typeof key === 'function' ? key : (item: T) => item[key];

// eslint-disable-next-line functional/no-loop-statements
for (const afterItem of after) {
const afterKey = keyFn(afterItem);
afterKeys.add(afterKey);

const match = before.find(beforeItem => keyFn(beforeItem) === afterKey);
if (match) {
// eslint-disable-next-line functional/immutable-data
pairs.push({ before: match, after: afterItem });
} else {
// eslint-disable-next-line functional/immutable-data
added.push(afterItem);
}
}

const removed = before.filter(
beforeItem => !afterKeys.has(keyFn(beforeItem)),
);

return {
pairs,
added,
removed,
};
}

export function comparePairs<T>(
pairs: Diff<T>[],
equalsFn: (pair: Diff<T>) => boolean,
) {
return pairs.reduce<{ changed: Diff<T>[]; unchanged: T[] }>(
(acc, pair) => ({
...acc,
...(equalsFn(pair)
? { unchanged: [...acc.unchanged, pair.after] }
: { changed: [...acc.changed, pair] }),
}),
{
changed: [],
unchanged: [],
},
);
}
78 changes: 78 additions & 0 deletions packages/utils/src/lib/diff.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { comparePairs, matchArrayItemsByKey } from './diff';

describe('matchArrayItemsByKey', () => {
it('should pair up items by key string', () => {
expect(
matchArrayItemsByKey({
before: [
{ id: 1, name: 'Foo' },
{ id: 2, name: 'Bar' },
],
after: [
{ id: 2, name: 'Baz' },
{ id: 3, name: 'Foo' },
],
key: 'id',
}),
).toEqual({
pairs: [
{ before: { id: 2, name: 'Bar' }, after: { id: 2, name: 'Baz' } },
],
added: [{ id: 3, name: 'Foo' }],
removed: [{ id: 1, name: 'Foo' }],
});
});

it('should pair up items by key function', () => {
expect(
matchArrayItemsByKey({
before: [
{ id: 1, name: 'Foo' },
{ id: 2, name: 'Bar' },
],
after: [
{ id: 2, name: 'Baz' },
{ id: 3, name: 'Foo' },
],
key: ({ id, name }) => `${id}-${name}`,
}),
).toEqual({
pairs: [],
added: [
{ id: 2, name: 'Baz' },
{ id: 3, name: 'Foo' },
],
removed: [
{ id: 1, name: 'Foo' },
{ id: 2, name: 'Bar' },
],
});
});
});

describe('comparePairs', () => {
it('should split changed and unchanged according to equals function', () => {
expect(
comparePairs(
[
{ before: { id: 1, value: 100 }, after: { id: 1, value: 100 } },
{ before: { id: 2, value: 200 }, after: { id: 2, value: 250 } },
{ before: { id: 3, value: 300 }, after: { id: 3, value: 300 } },
{ before: { id: 4, value: 400 }, after: { id: 4, value: 400 } },
{ before: { id: 5, value: 500 }, after: { id: 5, value: 600 } },
],
({ before, after }) => before.value === after.value,
),
).toEqual({
changed: [
{ before: { id: 2, value: 200 }, after: { id: 2, value: 250 } },
{ before: { id: 5, value: 500 }, after: { id: 5, value: 600 } },
],
unchanged: [
{ id: 1, value: 100 },
{ id: 3, value: 300 },
{ id: 4, value: 400 },
],
});
});
});
24 changes: 24 additions & 0 deletions packages/utils/src/lib/reports/flatten-plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Report } from '@code-pushup/models';

// generic type params infers ScoredGroup if ScoredReport provided
export function listGroupsFromAllPlugins<T extends Report>(
report: T,
): {
plugin: T['plugins'][0];
group: NonNullable<T['plugins'][0]['groups']>[0];
}[] {
return report.plugins.flatMap(
plugin => plugin.groups?.map(group => ({ plugin, group })) ?? [],
);
}

export function listAuditsFromAllPlugins<T extends Report>(
report: T,
): {
plugin: T['plugins'][0];
audit: T['plugins'][0]['audits'][0];
}[] {
return report.plugins.flatMap(plugin =>
plugin.audits.map(audit => ({ plugin, audit })),
);
}
106 changes: 106 additions & 0 deletions packages/utils/src/lib/reports/flatten-plugins.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { Report } from '@code-pushup/models';
import {
listAuditsFromAllPlugins,
listGroupsFromAllPlugins,
} from './flatten-plugins';

describe('listGroupsFromAllPlugins', () => {
it("should flatten plugins' groups", () => {
expect(
listGroupsFromAllPlugins({
plugins: [
{
slug: 'eslint',
groups: [
{ slug: 'problems' },
{ slug: 'suggestions' },
{ slug: 'formatting' },
],
},
{
slug: 'lighthouse',
groups: [
{ slug: 'performance' },
{ slug: 'accessibility' },
{ slug: 'best-practices' },
{ slug: 'seo' },
],
},
],
} as Report),
).toEqual([
{
group: expect.objectContaining({ slug: 'problems' }),
plugin: expect.objectContaining({ slug: 'eslint' }),
},
{
group: expect.objectContaining({ slug: 'suggestions' }),
plugin: expect.objectContaining({ slug: 'eslint' }),
},
{
group: expect.objectContaining({ slug: 'formatting' }),
plugin: expect.objectContaining({ slug: 'eslint' }),
},
{
group: expect.objectContaining({ slug: 'performance' }),
plugin: expect.objectContaining({ slug: 'lighthouse' }),
},
{
group: expect.objectContaining({ slug: 'accessibility' }),
plugin: expect.objectContaining({ slug: 'lighthouse' }),
},
{
group: expect.objectContaining({ slug: 'best-practices' }),
plugin: expect.objectContaining({ slug: 'lighthouse' }),
},
{
group: expect.objectContaining({ slug: 'seo' }),
plugin: expect.objectContaining({ slug: 'lighthouse' }),
},
]);
});
});

describe('listAuditsFromAllPlugins', () => {
it("should flatten plugins' audits", () => {
expect(
listAuditsFromAllPlugins({
plugins: [
{
slug: 'coverage',
audits: [
{ slug: 'function-coverage' },
{ slug: 'branch-coverage' },
{ slug: 'statement-coverage' },
],
},
{
slug: 'js-packages',
audits: [{ slug: 'audit' }, { slug: 'outdated' }],
},
],
} as Report),
).toEqual([
{
audit: expect.objectContaining({ slug: 'function-coverage' }),
plugin: expect.objectContaining({ slug: 'coverage' }),
},
{
audit: expect.objectContaining({ slug: 'branch-coverage' }),
plugin: expect.objectContaining({ slug: 'coverage' }),
},
{
audit: expect.objectContaining({ slug: 'statement-coverage' }),
plugin: expect.objectContaining({ slug: 'coverage' }),
},
{
audit: expect.objectContaining({ slug: 'audit' }),
plugin: expect.objectContaining({ slug: 'js-packages' }),
},
{
audit: expect.objectContaining({ slug: 'outdated' }),
plugin: expect.objectContaining({ slug: 'js-packages' }),
},
]);
});
});

0 comments on commit 4e87cd5

Please sign in to comment.