Skip to content

Commit 4ba31b7

Browse files
puskin94pmarchini
authored andcommittedOct 19, 2024
assert: make assertion_error use Myers diff algorithm
Fixes: #51733 Co-Authored-By: Pietro Marchini <pietro.marchini94@gmail.com> PR-URL: #54862 Reviewed-By: Ruben Bridgewater <ruben@bridgewater.de> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent 0130780 commit 4ba31b7

8 files changed

+731
-424
lines changed
 

‎lib/internal/assert/assertion_error.js

+138-242
Large diffs are not rendered by default.

‎lib/internal/assert/myers_diff.js

+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
'use strict';
2+
3+
const {
4+
Array,
5+
ArrayPrototypeFill,
6+
ArrayPrototypePush,
7+
ArrayPrototypeSlice,
8+
StringPrototypeEndsWith,
9+
} = primordials;
10+
11+
const colors = require('internal/util/colors');
12+
13+
const kNopLinesToCollapse = 5;
14+
15+
function areLinesEqual(actual, expected, checkCommaDisparity) {
16+
if (actual === expected) {
17+
return true;
18+
}
19+
if (checkCommaDisparity) {
20+
return `${actual},` === expected || actual === `${expected},`;
21+
}
22+
return false;
23+
}
24+
25+
function myersDiff(actual, expected, checkCommaDisparity = false) {
26+
const actualLength = actual.length;
27+
const expectedLength = expected.length;
28+
const max = actualLength + expectedLength;
29+
const v = ArrayPrototypeFill(Array(2 * max + 1), 0);
30+
31+
const trace = [];
32+
33+
for (let diffLevel = 0; diffLevel <= max; diffLevel++) {
34+
const newTrace = ArrayPrototypeSlice(v);
35+
ArrayPrototypePush(trace, newTrace);
36+
37+
for (let diagonalIndex = -diffLevel; diagonalIndex <= diffLevel; diagonalIndex += 2) {
38+
let x;
39+
if (diagonalIndex === -diffLevel ||
40+
(diagonalIndex !== diffLevel && v[diagonalIndex - 1 + max] < v[diagonalIndex + 1 + max])) {
41+
x = v[diagonalIndex + 1 + max];
42+
} else {
43+
x = v[diagonalIndex - 1 + max] + 1;
44+
}
45+
46+
let y = x - diagonalIndex;
47+
48+
while (x < actualLength && y < expectedLength && areLinesEqual(actual[x], expected[y], checkCommaDisparity)) {
49+
x++;
50+
y++;
51+
}
52+
53+
v[diagonalIndex + max] = x;
54+
55+
if (x >= actualLength && y >= expectedLength) {
56+
return backtrack(trace, actual, expected, checkCommaDisparity);
57+
}
58+
}
59+
}
60+
}
61+
62+
function backtrack(trace, actual, expected, checkCommaDisparity) {
63+
const actualLength = actual.length;
64+
const expectedLength = expected.length;
65+
const max = actualLength + expectedLength;
66+
67+
let x = actualLength;
68+
let y = expectedLength;
69+
const result = [];
70+
71+
for (let diffLevel = trace.length - 1; diffLevel >= 0; diffLevel--) {
72+
const v = trace[diffLevel];
73+
const diagonalIndex = x - y;
74+
let prevDiagonalIndex;
75+
76+
if (diagonalIndex === -diffLevel ||
77+
(diagonalIndex !== diffLevel && v[diagonalIndex - 1 + max] < v[diagonalIndex + 1 + max])) {
78+
prevDiagonalIndex = diagonalIndex + 1;
79+
} else {
80+
prevDiagonalIndex = diagonalIndex - 1;
81+
}
82+
83+
const prevX = v[prevDiagonalIndex + max];
84+
const prevY = prevX - prevDiagonalIndex;
85+
86+
while (x > prevX && y > prevY) {
87+
const value = !checkCommaDisparity ||
88+
StringPrototypeEndsWith(actual[x - 1], ',') ? actual[x - 1] : expected[y - 1];
89+
ArrayPrototypePush(result, { __proto__: null, type: 'nop', value });
90+
x--;
91+
y--;
92+
}
93+
94+
if (diffLevel > 0) {
95+
if (x > prevX) {
96+
ArrayPrototypePush(result, { __proto__: null, type: 'insert', value: actual[x - 1] });
97+
x--;
98+
} else {
99+
ArrayPrototypePush(result, { __proto__: null, type: 'delete', value: expected[y - 1] });
100+
y--;
101+
}
102+
}
103+
}
104+
105+
return result;
106+
}
107+
108+
function printSimpleMyersDiff(diff) {
109+
let message = '';
110+
111+
for (let diffIdx = diff.length - 1; diffIdx >= 0; diffIdx--) {
112+
const { type, value } = diff[diffIdx];
113+
if (type === 'insert') {
114+
message += `${colors.green}${value}${colors.white}`;
115+
} else if (type === 'delete') {
116+
message += `${colors.red}${value}${colors.white}`;
117+
} else {
118+
message += `${colors.white}${value}${colors.white}`;
119+
}
120+
}
121+
122+
return `\n${message}`;
123+
}
124+
125+
function printMyersDiff(diff, simple = false) {
126+
let message = '';
127+
let skipped = false;
128+
let nopCount = 0;
129+
130+
for (let diffIdx = diff.length - 1; diffIdx >= 0; diffIdx--) {
131+
const { type, value } = diff[diffIdx];
132+
const previousType = (diffIdx < (diff.length - 1)) ? diff[diffIdx + 1].type : null;
133+
const typeChanged = previousType && (type !== previousType);
134+
135+
if (typeChanged && previousType === 'nop') {
136+
// Avoid grouping if only one line would have been grouped otherwise
137+
if (nopCount === kNopLinesToCollapse + 1) {
138+
message += `${colors.white} ${diff[diffIdx + 1].value}\n`;
139+
} else if (nopCount === kNopLinesToCollapse + 2) {
140+
message += `${colors.white} ${diff[diffIdx + 2].value}\n`;
141+
message += `${colors.white} ${diff[diffIdx + 1].value}\n`;
142+
} if (nopCount >= (kNopLinesToCollapse + 3)) {
143+
message += `${colors.blue}...${colors.white}\n`;
144+
message += `${colors.white} ${diff[diffIdx + 1].value}\n`;
145+
skipped = true;
146+
}
147+
nopCount = 0;
148+
}
149+
150+
if (type === 'insert') {
151+
message += `${colors.green}+${colors.white} ${value}\n`;
152+
} else if (type === 'delete') {
153+
message += `${colors.red}-${colors.white} ${value}\n`;
154+
} else if (type === 'nop') {
155+
if (nopCount < kNopLinesToCollapse) {
156+
message += `${colors.white} ${value}\n`;
157+
}
158+
nopCount++;
159+
}
160+
}
161+
162+
message = message.trimEnd();
163+
164+
return { message: `\n${message}`, skipped };
165+
}
166+
167+
module.exports = { myersDiff, printMyersDiff, printSimpleMyersDiff };

‎test/parallel/test-assert-checktag.js

+8-2
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,20 @@ test('', { skip: !hasCrypto }, () => {
2929
() => assert.deepStrictEqual(date, fake),
3030
{
3131
message: 'Expected values to be strictly deep-equal:\n' +
32-
'+ actual - expected\n\n+ 2016-01-01T00:00:00.000Z\n- Date {}'
32+
'+ actual - expected\n' +
33+
'\n' +
34+
'+ 2016-01-01T00:00:00.000Z\n' +
35+
'- Date {}\n'
3336
}
3437
);
3538
assert.throws(
3639
() => assert.deepStrictEqual(fake, date),
3740
{
3841
message: 'Expected values to be strictly deep-equal:\n' +
39-
'+ actual - expected\n\n+ Date {}\n- 2016-01-01T00:00:00.000Z'
42+
'+ actual - expected\n' +
43+
'\n' +
44+
'+ Date {}\n' +
45+
'- 2016-01-01T00:00:00.000Z\n'
4046
}
4147
);
4248
}

‎test/parallel/test-assert-deep.js

+158-30
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,16 @@ test('deepEqual', () => {
7070
() => assert.deepStrictEqual(arr, buf),
7171
{
7272
code: 'ERR_ASSERTION',
73-
message: `${defaultMsgStartFull} ... Lines skipped\n\n` +
74-
'+ Uint8Array(4) [\n' +
75-
'- Buffer(4) [Uint8Array] [\n 120,\n...\n 122,\n 10\n ]'
73+
message: 'Expected values to be strictly deep-equal:\n' +
74+
'+ actual - expected\n' +
75+
'\n' +
76+
'+ Uint8Array(4) [\n' +
77+
'- Buffer(4) [Uint8Array] [\n' +
78+
' 120,\n' +
79+
' 121,\n' +
80+
' 122,\n' +
81+
' 10\n' +
82+
' ]\n'
7683
}
7784
);
7885
assert.deepEqual(arr, buf);
@@ -92,7 +99,7 @@ test('deepEqual', () => {
9299
' 122,\n' +
93100
' 10,\n' +
94101
'+ prop: 1\n' +
95-
' ]'
102+
' ]\n'
96103
}
97104
);
98105
assert.notDeepEqual(buf2, buf);
@@ -112,7 +119,7 @@ test('deepEqual', () => {
112119
' 122,\n' +
113120
' 10,\n' +
114121
'- prop: 5\n' +
115-
' ]'
122+
' ]\n'
116123
}
117124
);
118125
assert.notDeepEqual(arr, arr2);
@@ -127,7 +134,7 @@ test('date', () => {
127134
code: 'ERR_ASSERTION',
128135
message: `${defaultMsgStartFull}\n\n` +
129136
'+ 2016-01-01T00:00:00.000Z\n- MyDate 2016-01-01T00:00:00.000Z' +
130-
" {\n- '0': '1'\n- }"
137+
" {\n- '0': '1'\n- }\n"
131138
}
132139
);
133140
assert.throws(
@@ -136,7 +143,7 @@ test('date', () => {
136143
code: 'ERR_ASSERTION',
137144
message: `${defaultMsgStartFull}\n\n` +
138145
'+ MyDate 2016-01-01T00:00:00.000Z {\n' +
139-
"+ '0': '1'\n+ }\n- 2016-01-01T00:00:00.000Z"
146+
"+ '0': '1'\n+ }\n- 2016-01-01T00:00:00.000Z\n"
140147
}
141148
);
142149
});
@@ -151,7 +158,7 @@ test('regexp', () => {
151158
{
152159
code: 'ERR_ASSERTION',
153160
message: `${defaultMsgStartFull}\n\n` +
154-
"+ /test/\n- MyRegExp /test/ {\n- '0': '1'\n- }"
161+
"+ /test/\n- MyRegExp /test/ {\n- '0': '1'\n- }\n"
155162
}
156163
);
157164
});
@@ -474,7 +481,7 @@ test('es6 Maps and Sets', () => {
474481
{
475482
code: 'ERR_ASSERTION',
476483
message: `${defaultMsgStartFull}\n\n` +
477-
" Map(1) {\n+ 1 => 1\n- 1 => '1'\n }"
484+
" Map(1) {\n+ 1 => 1\n- 1 => '1'\n }\n"
478485
}
479486
);
480487
}
@@ -846,35 +853,55 @@ test('Additional tests', () => {
846853
{
847854
code: 'ERR_ASSERTION',
848855
name: 'AssertionError',
849-
message: `${defaultMsgStartFull}\n\n+ /ab/\n- /a/`
856+
message: 'Expected values to be strictly deep-equal:\n' +
857+
'+ actual - expected\n' +
858+
'\n' +
859+
'+ /ab/\n' +
860+
'- /a/\n'
850861
});
851862
assert.throws(
852863
() => assert.deepStrictEqual(/a/g, /a/),
853864
{
854865
code: 'ERR_ASSERTION',
855866
name: 'AssertionError',
856-
message: `${defaultMsgStartFull}\n\n+ /a/g\n- /a/`
867+
message: 'Expected values to be strictly deep-equal:\n' +
868+
'+ actual - expected\n' +
869+
'\n' +
870+
'+ /a/g\n' +
871+
'- /a/\n'
857872
});
858873
assert.throws(
859874
() => assert.deepStrictEqual(/a/i, /a/),
860875
{
861876
code: 'ERR_ASSERTION',
862877
name: 'AssertionError',
863-
message: `${defaultMsgStartFull}\n\n+ /a/i\n- /a/`
878+
message: 'Expected values to be strictly deep-equal:\n' +
879+
'+ actual - expected\n' +
880+
'\n' +
881+
'+ /a/i\n' +
882+
'- /a/\n'
864883
});
865884
assert.throws(
866885
() => assert.deepStrictEqual(/a/m, /a/),
867886
{
868887
code: 'ERR_ASSERTION',
869888
name: 'AssertionError',
870-
message: `${defaultMsgStartFull}\n\n+ /a/m\n- /a/`
889+
message: 'Expected values to be strictly deep-equal:\n' +
890+
'+ actual - expected\n' +
891+
'\n' +
892+
'+ /a/m\n' +
893+
'- /a/\n'
871894
});
872895
assert.throws(
873896
() => assert.deepStrictEqual(/aa/igm, /aa/im),
874897
{
875898
code: 'ERR_ASSERTION',
876899
name: 'AssertionError',
877-
message: `${defaultMsgStartFull}\n\n+ /aa/gim\n- /aa/im\n ^`
900+
message: 'Expected values to be strictly deep-equal:\n' +
901+
'+ actual - expected\n' +
902+
'\n' +
903+
'+ /aa/gim\n' +
904+
'- /aa/im\n'
878905
});
879906

880907
{
@@ -909,23 +936,23 @@ test('Having the same number of owned properties && the same set of keys', () =>
909936
{
910937
code: 'ERR_ASSERTION',
911938
name: 'AssertionError',
912-
message: `${defaultMsgStartFull}\n\n [\n+ 4\n- '4'\n ]`
939+
message: `${defaultMsgStartFull}\n\n [\n+ 4\n- '4'\n ]\n`
913940
});
914941
assert.throws(
915942
() => assert.deepStrictEqual({ a: 4 }, { a: 4, b: true }),
916943
{
917944
code: 'ERR_ASSERTION',
918945
name: 'AssertionError',
919946
message: `${defaultMsgStartFull}\n\n ` +
920-
'{\n a: 4,\n- b: true\n }'
947+
'{\n a: 4,\n- b: true\n }\n'
921948
});
922949
assert.throws(
923950
() => assert.deepStrictEqual(['a'], { 0: 'a' }),
924951
{
925952
code: 'ERR_ASSERTION',
926953
name: 'AssertionError',
927954
message: `${defaultMsgStartFull}\n\n` +
928-
"+ [\n+ 'a'\n+ ]\n- {\n- '0': 'a'\n- }"
955+
"+ [\n+ 'a'\n+ ]\n- {\n- '0': 'a'\n- }\n"
929956
});
930957
});
931958

@@ -964,25 +991,25 @@ test('Check extra properties on errors', () => {
964991
() => assert.deepStrictEqual(a, b),
965992
{
966993
operator: 'throws',
967-
message: `${defaultMsgStartFull}\n\n` +
968-
' [TypeError: foo] {\n+ foo: \'bar\'\n- foo: \'baz\'\n }',
994+
message: '',
969995
}
970996
),
971997
{
972998
message: 'Expected values to be strictly deep-equal:\n' +
973-
'+ actual - expected ... Lines skipped\n' +
999+
'+ actual - expected\n' +
9741000
'\n' +
9751001
' Comparison {\n' +
976-
" message: 'Expected values to be strictly deep-equal:\\n' +\n" +
977-
'...\n' +
978-
" ' [TypeError: foo] {\\n' +\n" +
979-
" \"+ foo: 'bar'\\n\" +\n" +
980-
"+ \"- foo: 'baz.'\\n\" +\n" +
981-
"- \"- foo: 'baz'\\n\" +\n" +
982-
" ' }',\n" +
1002+
"+ message: 'Expected values to be strictly deep-equal:\\n' +\n" +
1003+
"+ '+ actual - expected\\n' +\n" +
1004+
"+ '\\n' +\n" +
1005+
"+ ' [TypeError: foo] {\\n' +\n" +
1006+
`+ "+ foo: 'bar'\\n" +\n` +
1007+
`+ "- foo: 'baz.'\\n" +\n` +
1008+
"+ ' }\\n',\n" +
9831009
"+ operator: 'deepStrictEqual'\n" +
1010+
"- message: '',\n" +
9841011
"- operator: 'throws'\n" +
985-
' }'
1012+
' }\n'
9861013
}
9871014
);
9881015
});
@@ -995,7 +1022,7 @@ test('Check proxies', () => {
9951022
assert.throws(
9961023
() => assert.deepStrictEqual(arrProxy, [1, 2, 3]),
9971024
{ message: `${defaultMsgStartFull}\n\n` +
998-
' [\n 1,\n 2,\n- 3\n ]' }
1025+
' [\n 1,\n 2,\n- 3\n ]\n' }
9991026
);
10001027
util.inspect.defaultOptions = tmp;
10011028

@@ -1052,7 +1079,7 @@ test('Basic array out of bounds check', () => {
10521079
' 1,\n' +
10531080
' 2,\n' +
10541081
'+ 3\n' +
1055-
' ]'
1082+
' ]\n'
10561083
}
10571084
);
10581085
});
@@ -1347,6 +1374,107 @@ test('Comparing two different WeakSet instances', () => {
13471374
assertNotDeepOrStrict(weakSet1, weakSet2);
13481375
});
13491376

1377+
test('Comparing two arrays nested inside object, with overlapping elements', () => {
1378+
const actual = { a: { b: [1, 2, 3] } };
1379+
const expected = { a: { b: [3, 4, 5] } };
1380+
1381+
assert.throws(
1382+
() => assert.deepStrictEqual(actual, expected),
1383+
{
1384+
code: 'ERR_ASSERTION',
1385+
name: 'AssertionError',
1386+
message: 'Expected values to be strictly deep-equal:\n' +
1387+
'+ actual - expected\n' +
1388+
'\n' +
1389+
' {\n' +
1390+
' a: {\n' +
1391+
' b: [\n' +
1392+
'+ 1,\n' +
1393+
'+ 2,\n' +
1394+
' 3,\n' +
1395+
'- 4,\n' +
1396+
'- 5\n' +
1397+
' ]\n' +
1398+
' }\n' +
1399+
' }\n'
1400+
}
1401+
);
1402+
});
1403+
1404+
test('Comparing two arrays nested inside object, with overlapping elements, swapping keys', () => {
1405+
const actual = { a: { b: [1, 2, 3], c: 2 } };
1406+
const expected = { a: { b: 1, c: [3, 4, 5] } };
1407+
1408+
assert.throws(
1409+
() => assert.deepStrictEqual(actual, expected),
1410+
{
1411+
code: 'ERR_ASSERTION',
1412+
name: 'AssertionError',
1413+
message: 'Expected values to be strictly deep-equal:\n' +
1414+
'+ actual - expected\n' +
1415+
'\n' +
1416+
' {\n' +
1417+
' a: {\n' +
1418+
'+ b: [\n' +
1419+
'+ 1,\n' +
1420+
'+ 2,\n' +
1421+
'- b: 1,\n' +
1422+
'- c: [\n' +
1423+
' 3,\n' +
1424+
'- 4,\n' +
1425+
'- 5\n' +
1426+
' ],\n' +
1427+
'+ c: 2\n' +
1428+
' }\n' +
1429+
' }\n'
1430+
}
1431+
);
1432+
});
1433+
1434+
test('Detects differences in deeply nested arrays instead of seeing a new object', () => {
1435+
const actual = [
1436+
{ a: 1 },
1437+
2,
1438+
3,
1439+
4,
1440+
{ c: [1, 2, 3] },
1441+
];
1442+
const expected = [
1443+
{ a: 1 },
1444+
2,
1445+
3,
1446+
4,
1447+
{ c: [3, 4, 5] },
1448+
];
1449+
1450+
assert.throws(
1451+
() => assert.deepStrictEqual(actual, expected),
1452+
{
1453+
code: 'ERR_ASSERTION',
1454+
name: 'AssertionError',
1455+
message: 'Expected values to be strictly deep-equal:\n' +
1456+
'+ actual - expected\n' +
1457+
'... Skipped lines\n' +
1458+
'\n' +
1459+
' [\n' +
1460+
' {\n' +
1461+
' a: 1\n' +
1462+
' },\n' +
1463+
' 2,\n' +
1464+
'...\n' +
1465+
' c: [\n' +
1466+
'+ 1,\n' +
1467+
'+ 2,\n' +
1468+
' 3,\n' +
1469+
'- 4,\n' +
1470+
'- 5\n' +
1471+
' ]\n' +
1472+
' }\n' +
1473+
' ]\n'
1474+
}
1475+
);
1476+
});
1477+
13501478
// check URL
13511479
{
13521480
const a = new URL('http://foo');

‎test/parallel/test-assert.js

+221-133
Large diffs are not rendered by default.

‎test/pseudo-tty/test-assert-colors.js

+10-10
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,16 @@ assert.throws(() => {
99
assert.deepStrictEqual([1, 2, 2, 2, 2], [2, 2, 2, 2, 2]);
1010
}, (err) => {
1111
const expected = 'Expected values to be strictly deep-equal:\n' +
12-
'\u001b[32m+ actual\u001b[39m \u001b[31m- expected\u001b[39m' +
13-
' \u001b[34m...\u001b[39m Lines skipped\n\n' +
14-
' [\n' +
15-
'\u001b[32m+\u001b[39m 1,\n' +
16-
'\u001b[31m-\u001b[39m 2,\n' +
17-
' 2,\n' +
18-
'\u001b[34m...\u001b[39m\n' +
19-
' 2,\n' +
20-
' 2\n' +
21-
' ]';
12+
'\x1B[32m+ actual\x1B[39m \x1B[31m- expected\x1B[39m\n' +
13+
'\n' +
14+
'\x1B[39m [\n' +
15+
'\x1B[32m+\x1B[39m 1,\n' +
16+
'\x1B[39m 2,\n' +
17+
'\x1B[39m 2,\n' +
18+
'\x1B[39m 2,\n' +
19+
'\x1B[39m 2,\n' +
20+
'\x1B[31m-\x1B[39m 2\n' +
21+
'\x1B[39m ]\n';
2222
assert.strictEqual(err.message, expected);
2323
return true;
2424
});

‎test/pseudo-tty/test-assert-no-color.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,5 @@ assert.throws(
1515
'+ {}\n' +
1616
'- {\n' +
1717
'- foo: \'bar\'\n' +
18-
'- }',
18+
'- }\n',
1919
});

‎test/pseudo-tty/test-assert-position-indicator.js

+28-6
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,39 @@
22
require('../common');
33
const assert = require('assert');
44

5-
process.env.NODE_DISABLE_COLORS = true;
5+
process.env.NODE_DISABLE_COLORS = '1';
6+
7+
// Does not show the indicator when the terminal is too narrow
68
process.stderr.columns = 20;
79

8-
// Confirm that there is no position indicator.
910
assert.throws(
10-
() => { assert.strictEqual('a'.repeat(30), 'a'.repeat(31)); },
11+
() => { assert.strictEqual('123456789ABCDEFGHI', '1!3!5!7!9!BC!!!GHI'); },
1112
(err) => !err.message.includes('^'),
1213
);
1314

14-
// Confirm that there is a position indicator.
15+
// Does not show the indicator because the first difference is in the first 2 chars
16+
process.stderr.columns = 80;
17+
assert.throws(
18+
() => { assert.strictEqual('123456789ABCDEFGHI', '1!3!5!7!9!BC!!!GHI'); },
19+
{
20+
message: 'Expected values to be strictly equal:\n' +
21+
'+ actual - expected\n' +
22+
'\n' +
23+
"+ '123456789ABCDEFGHI'\n" +
24+
"- '1!3!5!7!9!BC!!!GHI'\n",
25+
},
26+
);
27+
28+
// Show the indicator because the first difference is in the 3 char
29+
process.stderr.columns = 80;
1530
assert.throws(
16-
() => { assert.strictEqual('aaaa', 'aaaaa'); },
17-
(err) => err.message.includes('^'),
31+
() => { assert.strictEqual('123456789ABCDEFGHI', '12!!5!7!9!BC!!!GHI'); },
32+
{
33+
message: 'Expected values to be strictly equal:\n' +
34+
'+ actual - expected\n' +
35+
'\n' +
36+
"+ '123456789ABCDEFGHI'\n" +
37+
"- '12!!5!7!9!BC!!!GHI'\n" +
38+
' ^\n',
39+
},
1840
);

0 commit comments

Comments
 (0)
Please sign in to comment.