Skip to content

Commit

Permalink
Unify snapshots comparison (#6295)
Browse files Browse the repository at this point in the history
## Summary
Unify code for snapshot comparison on Android and IOS.
Also a lot of outdated code was cleaned out, as we have decided not to
return snapshots of multiple views, but always for a certain predefined
one.

## Test plan
  • Loading branch information
Latropos authored Jul 26, 2024
1 parent e2da22f commit 5c44da9
Show file tree
Hide file tree
Showing 3 changed files with 88 additions and 113 deletions.
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { makeMutable } from 'react-native-reanimated';
import type { Operation, OperationUpdate } from '../types';
import { isValidPropName } from '../types';
import type { MultiViewSnapshot, SingleViewSnapshot } from '../matchers/snapshotMatchers';
import type { TestComponent } from '../TestComponent';
import { SyncUIRunner } from '../utils/SyncUIRunner';
import { convertDecimalColor } from '../utils/util';

export type SingleViewSnapshot = Array<OperationUpdate>;
type MultiViewSnapshot = Record<number, SingleViewSnapshot>;

type JsUpdate = {
tag: number;
shadowNodeWrapper?: unknown;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
toBeCalledUIMatcher,
toBeCalledJSMatcher,
} from './rawMatchers';
import type { SingleViewSnapshot } from './snapshotMatchers';
import { compareSnapshots } from './snapshotMatchers';
import type { SingleViewSnapshot } from '../TestRunner/UpdatesContainer';

export class Matchers {
private _negation = false;
Expand All @@ -36,8 +36,8 @@ export class Matchers {
public toBeCalledUI = this.decorateMatcher(toBeCalledUIMatcher);
public toBeCalledJS = this.decorateMatcher(toBeCalledJSMatcher);

public toMatchSnapshots(expectedSnapshots: SingleViewSnapshot | Record<number, SingleViewSnapshot>) {
const capturedSnapshots = this._currentValue as SingleViewSnapshot | Record<number, SingleViewSnapshot>;
public toMatchSnapshots(expectedSnapshots: SingleViewSnapshot) {
const capturedSnapshots = this._currentValue as SingleViewSnapshot;
if (capturedSnapshots) {
const mismatchError = compareSnapshots(expectedSnapshots, capturedSnapshots, false);
if (mismatchError) {
Expand All @@ -48,11 +48,8 @@ export class Matchers {
}
}

public toMatchNativeSnapshots(
expectedSnapshots: SingleViewSnapshot | Record<number, SingleViewSnapshot>,
expectNegativeValueMismatch = false,
) {
const capturedSnapshots = this._currentValue as SingleViewSnapshot | Record<number, SingleViewSnapshot>;
public toMatchNativeSnapshots(expectedSnapshots: SingleViewSnapshot, expectNegativeValueMismatch = false) {
const capturedSnapshots = this._currentValue as SingleViewSnapshot;
const mismatchError = compareSnapshots(expectedSnapshots, capturedSnapshots, true, expectNegativeValueMismatch);
if (mismatchError) {
this._testCase.errors.push(mismatchError);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,47 @@ import { formatSnapshotMismatch, green, red, yellow } from '../utils/stringForma
import type { OperationUpdate, Mismatch } from '../types';
import { ComparisonMode, isValidPropName } from '../types';
import { getComparator, getComparisonModeForProp } from './Comparators';
import type { SingleViewSnapshot } from '../TestRunner/UpdatesContainer';

export type SingleViewSnapshot = Array<OperationUpdate>;
export type MultiViewSnapshot = Record<number, SingleViewSnapshot>;
export type Snapshot = SingleViewSnapshot | MultiViewSnapshot;

function isJsAndNativeSnapshotsEqual(
jsSnapshots: Array<OperationUpdate>,
nativeSnapshots: Array<OperationUpdate>,
i: number,
function compareSnapshot(
expectedSnapshot: OperationUpdate,
capturedSnapshot: OperationUpdate,
expectNegativeValueMismatch: boolean,
): boolean {
/**
) {
const keys = Object.keys(capturedSnapshot) as Array<keyof OperationUpdate>;
for (const key of keys) {
const jsValue = capturedSnapshot[key];
const nativeValue = expectedSnapshot[key];
const comparisonMode = isValidPropName(key) ? getComparisonModeForProp(key) : ComparisonMode.AUTO;
const isEqual = getComparator(comparisonMode);
const expectMismatch = jsValue < 0 && expectNegativeValueMismatch;
const valuesAreMatching = isEqual(jsValue, nativeValue);
if ((!valuesAreMatching && !expectMismatch) || (valuesAreMatching && expectMismatch)) {
return false;
}
}
return true;
}

function compareSingleViewSnapshots(
expectedSnapshots: SingleViewSnapshot, // native
capturedSnapshots: SingleViewSnapshot, // js
native: boolean,
expectNegativeValueMismatch = false,
) {
const mismatchedSnapshots: Array<Mismatch> = [];

const matchingLength = Math.abs(expectedSnapshots.length - capturedSnapshots.length) <= Number(native);
if (!matchingLength) {
return `Expected ${green(expectedSnapshots.length - Number(native))} snapshots, but received ${red(
capturedSnapshots.length,
)} snapshots\n`;
}

for (let i = 0; i < capturedSnapshots.length - 1; i++) {
if (native) {
const capturedSnapshot = capturedSnapshots[i];
/**
The TestRunner can collect two types of snapshots:
- JS snapshots: animation updates sent via `_updateProps`
- Native snapshots: snapshots obtained from the native side via `getViewProp`
Expand All @@ -23,25 +52,23 @@ function isJsAndNativeSnapshotsEqual(
to take a native snapshot immediately after the `_updateProps` call. To address this issue,
we need to wait for the next frame before capturing the native snapshot.
That's why native snapshots are one frame behind JS snapshots. To account for this delay,
one additional native snapshot is taken during the execution of the `getNativeSnapshots` function.
*/
one additional native snapshot is taken during the execution of the `getNativeSnapshots` function. */
const expectedSnapshot = expectedSnapshots[i + Number(native)];
const snapshotMatching = compareSnapshot(expectedSnapshot, capturedSnapshot, expectNegativeValueMismatch);

const jsSnapshot = jsSnapshots[i];
const nativeSnapshot = nativeSnapshots[i + 1];

const keys = Object.keys(jsSnapshot) as Array<keyof OperationUpdate>;
for (const key of keys) {
const jsValue = jsSnapshot[key];
const nativeValue = nativeSnapshot[key];
const comparisonMode = isValidPropName(key) ? getComparisonModeForProp(key) : ComparisonMode.AUTO;
const isEqual = getComparator(comparisonMode);
const expectMismatch = jsValue < 0 && expectNegativeValueMismatch;
const valuesAreMatching = isEqual(jsValue, nativeValue);
if ((!valuesAreMatching && !expectMismatch) || (valuesAreMatching && expectMismatch)) {
return false;
if (!snapshotMatching) {
mismatchedSnapshots.push({
index: i,
expectedSnapshot: expectedSnapshots[i + Number(native)],
capturedSnapshot: capturedSnapshots[i],
});
}
}
}
return true;

if (mismatchedSnapshots.length > 0) {
return formatSnapshotMismatch(mismatchedSnapshots, false);
}
}

/**
Expand All @@ -60,102 +87,51 @@ function compareSingleViewNativeSnapshots(
jsUpdates: Array<OperationUpdate>,
expectNegativeValueMismatch = false,
): string | undefined {
if (!nativeSnapshots || !jsUpdates) {
return `Missing snapshot`;
}
if (jsUpdates.length !== nativeSnapshots.length - 1 && jsUpdates.length !== nativeSnapshots.length) {
return `Expected ${green(jsUpdates.length)} snapshots, but received ${red(nativeSnapshots.length - 1)} snapshots\n`;
}
const mismatchedSnapshots: Array<Mismatch> = [];
for (let i = 0; i < jsUpdates.length - 1; i++) {
if (!isJsAndNativeSnapshotsEqual(jsUpdates, nativeSnapshots, i, expectNegativeValueMismatch)) {
mismatchedSnapshots.push({ index: i, expectedSnapshot: nativeSnapshots[i + 1], capturedSnapshot: jsUpdates[i] });
}
}
return mismatchedSnapshots.length === 0 ? undefined : formatSnapshotMismatch(mismatchedSnapshots, true);
return compareSingleViewSnapshots(nativeSnapshots, jsUpdates, true, expectNegativeValueMismatch);
}

function compareSingleViewJsSnapshots(
expectedSnapshots: SingleViewSnapshot,
capturedSnapshots: SingleViewSnapshot,
): string | undefined {
const mismatchedSnapshots: Array<Mismatch> = [];
if (expectedSnapshots.length !== capturedSnapshots.length) {
return `Expected ${green(expectedSnapshots.length)} snapshots, but received ${red(
capturedSnapshots.length,
)} snapshots\n`;
}
expectedSnapshots.forEach((expectedSnapshot: OperationUpdate, index: number) => {
const capturedSnapshot = capturedSnapshots[index];
const isEquals = getComparator(ComparisonMode.AUTO);
if (!isEquals(expectedSnapshot, capturedSnapshot)) {
mismatchedSnapshots.push({ index, expectedSnapshot, capturedSnapshot });
}
});
if (mismatchedSnapshots.length > 0) {
return formatSnapshotMismatch(mismatchedSnapshots, false);
return compareSingleViewSnapshots(expectedSnapshots, capturedSnapshots, false, false);
}

function isSingleViewSnapshot(snapshot: unknown): snapshot is SingleViewSnapshot {
if (!Array.isArray(snapshot)) {
return false;
}
const allElementsAreObjects = snapshot.reduce(
(accumulator, currentValue) => accumulator && typeof currentValue === 'object',
true,
);
return allElementsAreObjects;
}

export function compareSnapshots(
expectedSnapshots: SingleViewSnapshot | Record<number, SingleViewSnapshot>,
capturedSnapshots: SingleViewSnapshot | Record<number, SingleViewSnapshot>,
expectedSnapshots: unknown,
capturedSnapshots: unknown,
native: boolean,
expectNegativeValueMismatch = false,
): string | null {
let errorMessage = '';

const compareSingleViewSnapshots = (expected: SingleViewSnapshot, captured: SingleViewSnapshot) => {
return native
? compareSingleViewNativeSnapshots(expected, captured, expectNegativeValueMismatch)
: compareSingleViewJsSnapshots(expected, captured);
};
if (!isSingleViewSnapshot(expectedSnapshots)) {
errorMessage += `Unexpected type of expected snapshots: ${typeof capturedSnapshots}\n`;
} else if (!isSingleViewSnapshot(capturedSnapshots)) {
errorMessage += `Unexpected type of captured snapshots: ${typeof capturedSnapshots}\n`;
} else {
const compareSingleViewSnapshots = (expected: SingleViewSnapshot, captured: SingleViewSnapshot) => {
return native
? compareSingleViewNativeSnapshots(expected, captured, expectNegativeValueMismatch)
: compareSingleViewJsSnapshots(expected, captured);
};

if (Array.isArray(expectedSnapshots)) {
if (!Array.isArray(capturedSnapshots)) {
return `Expected snapshots of ${green('only one')} view, received snapshots of ${red(
Object.keys(capturedSnapshots).length,
)}`;
} else {
if (!Array.isArray(capturedSnapshots)) {
return `Unexpected type of captured snapshots: ${typeof capturedSnapshots}`;
}
const err = compareSingleViewSnapshots(expectedSnapshots, capturedSnapshots);
if (err) {
errorMessage = 'Snapshot mismatch: \n' + err;
}
const err = compareSingleViewSnapshots(expectedSnapshots, capturedSnapshots);
if (err) {
errorMessage = 'Snapshot mismatch: \n' + err;
}
}

if (!Array.isArray(expectedSnapshots) && typeof expectedSnapshots === 'object') {
const expectedViewNum = Object.keys(expectedSnapshots)?.length;
const capturedViewNum = Object.keys(capturedSnapshots)?.length;

if (Array.isArray(capturedSnapshots)) {
return `Expected snapshots of ${green(expectedViewNum)} views, received only ${red('one')}`;
} else {
if (!capturedSnapshots || typeof capturedSnapshots !== 'object') {
return `Unexpected type of captured snapshots: ${typeof capturedSnapshots}`;
}
if (expectedViewNum !== capturedViewNum) {
return `Expected snapshots of ${green(expectedViewNum)} views, received ${red(capturedViewNum)}`;
}
// order of view in snapshots is constant, so we can compare them one by one
for (let i = 0; i < expectedViewNum; i++) {
const viewErrorMessage = compareSingleViewSnapshots(expectedSnapshots[i], capturedSnapshots[i]);
if (viewErrorMessage) {
errorMessage += `Snapshot mismatch for view ${i}: \n` + viewErrorMessage;
}
}
}
}

if (!Array.isArray(expectedSnapshots) && typeof expectedSnapshots !== 'object') {
errorMessage = `Unexpected type of the ${native ? 'JS' : 'expected'} snapshot ` + red(typeof expectedSnapshots);
}
if (!Array.isArray(capturedSnapshots) && typeof capturedSnapshots !== 'object') {
errorMessage = `Unexpected type of snapshot ` + red(typeof expectedSnapshots);
}

return errorMessage !== '' ? `${native ? yellow('Native Snapshot: ') : ''}${errorMessage}` : null;
}

0 comments on commit 5c44da9

Please sign in to comment.