Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(testing): improved strings diff #948

Merged
merged 12 commits into from
Jun 24, 2021
77 changes: 77 additions & 0 deletions testing/_diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,80 @@ export function diff<T>(A: T[], B: T[]): Array<DiffResult<T>> {
),
];
}

/**
* Renders the differences between the actual and expected strings
* Partially inspired from https://github.com/kpdecker/jsdiff
* @param A Actual string
* @param B Expected string
*/
export function diffstr(A: string, B: string) {
function tokenize(string: string, { wordDiff = false } = {}): string[] {
if (wordDiff) {
// Split string on whitespace symbols
const tokens = string.split(/([^\S\r\n]+|[()[\]{}'"\r\n]|\b)/);
// Extended Latin character set
const words =
/^[a-zA-Z\u{C0}-\u{FF}\u{D8}-\u{F6}\u{F8}-\u{2C6}\u{2C8}-\u{2D7}\u{2DE}-\u{2FF}\u{1E00}-\u{1EFF}]+$/u;

// Join boundary splits that we do not consider to be boundaries and merge empty strings surrounded by word chars
for (let i = 0; i < tokens.length - 1; i++) {
if (
!tokens[i + 1] && tokens[i + 2] && words.test(tokens[i]) &&
words.test(tokens[i + 2])
) {
tokens[i] += tokens[i + 2];
tokens.splice(i + 1, 2);
i--;
}
}
return tokens.filter((token) => token);
} else {
// Split string on new lines symbols
const tokens = [], lines = string.split(/(\n|\r\n)/);

// Ignore final empty token when text ends with a newline
if (!lines[lines.length - 1]) {
lines.pop();
}

// Merge the content and line separators into single tokens
for (let i = 0; i < lines.length; i++) {
if (i % 2) {
tokens[tokens.length - 1] += lines[i];
} else {
tokens.push(lines[i]);
}
}
return tokens;
}
}

// Try to autodetect the most suitable string rendering, starting by words-diff
let wordDiff = true;
let diffResult = diff(tokenize(A, { wordDiff }), tokenize(B, { wordDiff }));
const common =
diffResult.filter(({ type, value }) =>
type === DiffType.common && value.trim()
).length;
const edited =
diffResult.filter(({ type }) =>
type === DiffType.added || type === DiffType.removed
).length;

// If edited ratio is too high switch to multi-line diff instead
// We add a trailing newline to ensure that last tokens are displayed correctly
if (
edited / (common + edited) >
0.85 **
Math.max(1, A.match(/\n/g)?.length ?? 0, B.match(/\n/g)?.length ?? 0)
lowlighter marked this conversation as resolved.
Show resolved Hide resolved
) {
wordDiff = false;
diffResult = diff(
tokenize(`${A}\n`, { wordDiff }),
tokenize(`${B}\n`, { wordDiff }),
);
}

return { diffResult, wordDiff };
}
46 changes: 44 additions & 2 deletions testing/_diff_test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license.
import { diff } from "./_diff.ts";
import { assertEquals } from "../testing/asserts.ts";
import { diff, diffstr } from "./_diff.ts";
import { assert, assertEquals } from "../testing/asserts.ts";

Deno.test({
name: "empty",
Expand Down Expand Up @@ -109,3 +109,45 @@ Deno.test({
]);
},
});

Deno.test({
name: '"a b c d" vs "a b x d e" (diffstr - word-diff)',
fn(): void {
const { diffResult, wordDiff } = diffstr(
[..."abcd"].join(" "),
[..."abxde"].join(" "),
);
assert(wordDiff);
assertEquals(diffResult, [
{ type: "common", value: "a" },
{ type: "common", value: " " },
{ type: "common", value: "b" },
{ type: "common", value: " " },
{ type: "added", value: "x" },
{ type: "removed", value: "c" },
{ type: "common", value: " " },
{ type: "common", value: "d" },
{ type: "added", value: " " },
{ type: "added", value: "e" },
]);
},
});

Deno.test({
name: '"a b c d" vs "a b x d e" (diffstr - multiline-diff)',
fn(): void {
const { diffResult, wordDiff } = diffstr(
[..."abcd"].join("\n"),
[..."abxde"].join("\n"),
);
assert(!wordDiff);
assertEquals(diffResult, [
{ type: "common", value: "a\n" },
{ type: "common", value: "b\n" },
{ type: "added", value: "x\n" },
{ type: "removed", value: "c\n" },
{ type: "common", value: "d\n" },
{ type: "added", value: "e\n" },
]);
},
});
56 changes: 40 additions & 16 deletions testing/asserts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// for AssertionError messages in browsers.

import { bold, gray, green, red, stripColor, white } from "../fmt/colors.ts";
import { diff, DiffResult, DiffType } from "./_diff.ts";
import { diff, DiffResult, diffstr, DiffType } from "./_diff.ts";

const CAN_NOT_DISPLAY = "[Cannot display]";

Expand Down Expand Up @@ -66,8 +66,11 @@ function createSign(diffType: DiffType): string {
}
}

function buildMessage(diffResult: ReadonlyArray<DiffResult<string>>): string[] {
const messages: string[] = [];
function buildMessage(
diffResult: ReadonlyArray<DiffResult<string>>,
{ sign = true, stringDiff = false } = {},
): string[] {
const messages: string[] = [], diffMessages: string[] = [];
messages.push("");
messages.push("");
messages.push(
Expand All @@ -79,8 +82,11 @@ function buildMessage(diffResult: ReadonlyArray<DiffResult<string>>): string[] {
messages.push("");
diffResult.forEach((result: DiffResult<string>): void => {
const c = createColor(result.type);
messages.push(c(`${createSign(result.type)}${result.value}`));
diffMessages.push(
c(`${sign ? createSign(result.type) : ""}${result.value}`),
);
});
messages.push(...(stringDiff ? [diffMessages.join("")] : diffMessages));
messages.push("");

return messages;
Expand Down Expand Up @@ -207,12 +213,21 @@ export function assertEquals(
const actualString = _format(actual);
const expectedString = _format(expected);
try {
const diffResult = diff(
actualString.split("\n"),
expectedString.split("\n"),
);
const diffMsg = buildMessage(diffResult).join("\n");
message = `Values are not equal:\n${diffMsg}`;
if ((typeof actual === "string") && (typeof expected === "string")) {
const { diffResult, wordDiff } = diffstr(actual, expected);
const diffMsg = buildMessage(diffResult, {
sign: !wordDiff,
stringDiff: true,
}).join("\n");
message = `Values are not equal:\n${diffMsg}`;
} else {
const diffResult = diff(
actualString.split("\n"),
expectedString.split("\n"),
);
const diffMsg = buildMessage(diffResult).join("\n");
message = `Values are not equal:\n${diffMsg}`;
}
} catch {
message = `\n${red(CAN_NOT_DISPLAY)} + \n\n`;
}
Expand Down Expand Up @@ -309,12 +324,21 @@ export function assertStrictEquals(
}\n`;
} else {
try {
const diffResult = diff(
actualString.split("\n"),
expectedString.split("\n"),
);
const diffMsg = buildMessage(diffResult).join("\n");
message = `Values are not strictly equal:\n${diffMsg}`;
if ((typeof actual === "string") && (typeof expected === "string")) {
const { diffResult, wordDiff } = diffstr(actual, expected);
const diffMsg = buildMessage(diffResult, {
sign: !wordDiff,
stringDiff: true,
}).join("\n");
message = `Values are not equal:\n${diffMsg}`;
} else {
const diffResult = diff(
actualString.split("\n"),
expectedString.split("\n"),
);
const diffMsg = buildMessage(diffResult).join("\n");
message = `Values are not equal:\n${diffMsg}`;
}
} catch {
message = `\n${red(CAN_NOT_DISPLAY)} + \n\n`;
}
Expand Down