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
111 changes: 111 additions & 0 deletions testing/_diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export enum DiffType {
export interface DiffResult<T> {
type: DiffType;
value: T;
details?: Array<DiffResult<T>>;
}

const REMOVED = 1;
Expand Down Expand Up @@ -226,3 +227,113 @@ 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;
}
}

// Create details by filtering revelant word-diff for current line
// and merge "space-diff" if surrounded by word-diff for cleaner displays
function createDetails(
line: DiffResult<string>,
tokens: Array<DiffResult<string>>,
) {
return tokens.filter(({ type }) =>
type === line.type || type === DiffType.common
).map((result, i, t) => {
if (
(result.type === DiffType.common) && (t[i - 1]) &&
(t[i - 1]?.type === t[i + 1]?.type)
) {
result.type = t[i - 1].type;
}
return result;
});
}

// Compute multi-line diff
const diffResult = diff(tokenize(`${A}\n`), tokenize(`${B}\n`));
const added = [], removed = [];
for (const result of diffResult) {
if (result.type === DiffType.added) {
added.push(result);
}
if (result.type === DiffType.removed) {
removed.push(result);
}
}

// Compute word-diff
const aLines = added.length < removed.length ? added : removed;
const bLines = aLines === removed ? added : removed;
for (const a of aLines) {
let tokens = [] as Array<DiffResult<string>>,
b: undefined | DiffResult<string>;
// Search another diff line with at least one common token
while (bLines.length) {
b = bLines.shift();
tokens = diff(
tokenize(a.value, { wordDiff: true }),
tokenize(b?.value ?? "", { wordDiff: true }),
);
if (
tokens.some(({ type, value }) =>
type === DiffType.common && value.trim().length
)
) {
break;
}
}
// Register word-diff details
a.details = createDetails(a, tokens);
if (b) {
b.details = createDetails(b, tokens);
}
}

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

Deno.test({
Expand Down Expand Up @@ -109,3 +109,41 @@ Deno.test({
]);
},
});

Deno.test({
name: '"a b c d" vs "a b x d e" (diffstr)',
fn(): void {
const diffResult = diffstr(
[..."abcd"].join("\n"),
[..."abxde"].join("\n"),
);
assertEquals(diffResult, [
{ type: "common", value: "a\n" },
{ type: "common", value: "b\n" },
{ type: "added", value: "x\n" },
{
type: "removed",
value: "c\n",
details: [{ type: "removed", value: "c" }, {
type: "common",
value: "\n",
}],
},
{ type: "common", value: "d\n" },
{
type: "added",
value: "e\n",
details: [
{
type: "added",
value: "e",
},
{
type: "common",
value: "\n",
},
],
},
]);
},
});
60 changes: 42 additions & 18 deletions testing/asserts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,17 @@
// This module is browser compatible. Do not rely on good formatting of values
// for AssertionError messages in browsers.

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

const CAN_NOT_DISPLAY = "[Cannot display]";

Expand Down Expand Up @@ -40,12 +49,16 @@ export function _format(v: unknown): string {
* Colors the output of assertion diffs
* @param diffType Difference type, either added or removed
*/
function createColor(diffType: DiffType): (s: string) => string {
function createColor(
diffType: DiffType,
{ background = false } = {},
): (s: string) => string {
switch (diffType) {
case DiffType.added:
return (s: string): string => green(bold(s));
return (s: string): string =>
background ? bgGreen(white(s)) : green(bold(s));
case DiffType.removed:
return (s: string): string => red(bold(s));
return (s: string): string => background ? bgRed(white(s)) : red(bold(s));
default:
return white;
}
Expand All @@ -66,8 +79,11 @@ function createSign(diffType: DiffType): string {
}
}

function buildMessage(diffResult: ReadonlyArray<DiffResult<string>>): string[] {
const messages: string[] = [];
function buildMessage(
diffResult: ReadonlyArray<DiffResult<string>>,
{ stringDiff = false } = {},
): string[] {
const messages: string[] = [], diffMessages: string[] = [];
messages.push("");
messages.push("");
messages.push(
Expand All @@ -79,8 +95,14 @@ 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}`));
const line = result.details?.map((detail) =>
detail.type !== DiffType.common
? createColor(detail.type, { background: true })(detail.value)
: detail.value
).join("") ?? result.value;
diffMessages.push(c(`${createSign(result.type)}${line}`));
});
messages.push(...(stringDiff ? [diffMessages.join("")] : diffMessages));
messages.push("");

return messages;
Expand Down Expand Up @@ -207,11 +229,12 @@ 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");
const stringDiff = (typeof actual === "string") &&
(typeof expected === "string");
const diffResult = stringDiff
? diffstr(actual as string, expected as string)
: diff(actualString.split("\n"), expectedString.split("\n"));
const diffMsg = buildMessage(diffResult, { stringDiff }).join("\n");
message = `Values are not equal:\n${diffMsg}`;
} catch {
message = `\n${red(CAN_NOT_DISPLAY)} + \n\n`;
Expand Down Expand Up @@ -309,11 +332,12 @@ export function assertStrictEquals(
}\n`;
} else {
try {
const diffResult = diff(
actualString.split("\n"),
expectedString.split("\n"),
);
const diffMsg = buildMessage(diffResult).join("\n");
const stringDiff = (typeof actual === "string") &&
(typeof expected === "string");
const diffResult = stringDiff
? diffstr(actual as string, expected as string)
: diff(actualString.split("\n"), expectedString.split("\n"));
const diffMsg = buildMessage(diffResult, { stringDiff }).join("\n");
message = `Values are not strictly equal:\n${diffMsg}`;
} catch {
message = `\n${red(CAN_NOT_DISPLAY)} + \n\n`;
Expand Down
17 changes: 17 additions & 0 deletions testing/asserts_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,23 @@ Deno.test("assertEquals diff for differently ordered objects", () => {
);
});

Deno.test("assert diff formatting (strings)", () => {
assertThrows(
() => {
assertEquals([..."abcd"].join("\n"), [..."abxde"].join("\n"));
},
undefined,
`
a
b
${green("+ x")}
${red("- c")}
d
${green("+ e")}
`,
);
});

// Check that the diff formatter overrides some default behaviours of
// `Deno.inspect()` which are problematic for diffing.
Deno.test("assert diff formatting", () => {
Expand Down