More bindings tests
This commit is contained in:
parent
45e91c655d
commit
cf09568ba7
2 changed files with 268 additions and 3 deletions
|
|
@ -1,5 +1,9 @@
|
|||
import * as webApi from './index';
|
||||
import * as rnApi from './index.rn';
|
||||
import * as webBindings from 'reconcile-text';
|
||||
import * as rnBindings from '../pkg-rn/reconcile_text.js';
|
||||
import { jest } from '@jest/globals';
|
||||
import type { TextWithOptionalCursors } from './core';
|
||||
import { installWasmLeakDetector, checkForWasmLeaks } from './wasm-leak-detector';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
|
@ -23,8 +27,8 @@ afterEach(() => {
|
|||
// same `src/core.ts` wrapper and expose an identical public API, so the behavioural
|
||||
// suite below runs against both to guarantee they stay in lock-step.
|
||||
const backends = [
|
||||
{ name: 'web/node (WebAssembly)', api: webApi },
|
||||
{ name: 'React Native (wasm2js)', api: rnApi },
|
||||
{ name: 'web/node (WebAssembly)', api: webApi, bindings: webBindings },
|
||||
{ name: 'React Native (wasm2js)', api: rnApi, bindings: rnBindings },
|
||||
];
|
||||
|
||||
describe.each(backends)('reconcile [$name]', ({ api }) => {
|
||||
|
|
@ -89,6 +93,204 @@ describe.each(backends)('reconcile [$name]', ({ api }) => {
|
|||
});
|
||||
});
|
||||
|
||||
describe.each(backends)('WASM cleanup after exceptions [$name]', ({ api, bindings }) => {
|
||||
const mergeOperations = ['reconcile', 'reconcileWithHistory'] as const;
|
||||
const operations = [...mergeOperations, 'diff'] as const;
|
||||
const left = {
|
||||
text: 'Hello world',
|
||||
cursors: [{ id: 1, position: 2 }],
|
||||
};
|
||||
|
||||
function failingString(error: Error): string {
|
||||
return {
|
||||
get length() {
|
||||
throw error;
|
||||
},
|
||||
[Symbol.iterator]() {
|
||||
return 'Hello world'[Symbol.iterator]();
|
||||
},
|
||||
} as unknown as string;
|
||||
}
|
||||
|
||||
it.each(mergeOperations)(
|
||||
'%s frees the left input if reading the right input throws',
|
||||
(operation) => {
|
||||
const error = new Error('right input failed');
|
||||
const right = {
|
||||
get text(): string {
|
||||
throw error;
|
||||
},
|
||||
cursors: [],
|
||||
};
|
||||
|
||||
expect(() => api[operation]('Hello', left, right)).toThrow(error);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(operations)(
|
||||
'%s frees cursors when a later input cursor throws',
|
||||
(operation) => {
|
||||
const error = new Error('cursor input failed');
|
||||
const input = {
|
||||
text: 'Hello world',
|
||||
cursors: [
|
||||
{ id: 2, position: 1 },
|
||||
{
|
||||
get id(): number {
|
||||
throw error;
|
||||
},
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
const invoke = () =>
|
||||
operation === 'diff'
|
||||
? api.diff('Hello', input)
|
||||
: api[operation]('Hello', left, input);
|
||||
|
||||
expect(invoke).toThrow(error);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(operations)(
|
||||
'%s frees cursors if the text constructor throws before consuming them',
|
||||
(operation) => {
|
||||
const error = new Error('text input failed');
|
||||
const input: TextWithOptionalCursors = {
|
||||
text: failingString(error),
|
||||
cursors: [{ id: 2, position: 1 }],
|
||||
};
|
||||
const invoke = () =>
|
||||
operation === 'diff'
|
||||
? api.diff('Hello', input)
|
||||
: api[operation]('Hello', left, input);
|
||||
|
||||
expect(invoke).toThrow(error);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(operations)('%s frees inputs when the backend throws', (operation) => {
|
||||
const error = new Error('backend failed');
|
||||
const original = failingString(error);
|
||||
const invoke = () =>
|
||||
operation === 'diff'
|
||||
? api.diff(original, left)
|
||||
: api[operation](original, left, 'Hi world');
|
||||
|
||||
expect(invoke).toThrow(error);
|
||||
});
|
||||
|
||||
describe.each(operations)('%s cursor validation', (operation) => {
|
||||
it.each([-1, 0.5, NaN, Infinity, 4])(
|
||||
'rejects position %s before the WASM constructor can trap',
|
||||
(position) => {
|
||||
const input = {
|
||||
text: 'a😀b',
|
||||
cursors: [
|
||||
{ id: 2, position: 1 },
|
||||
{ id: 3, position },
|
||||
],
|
||||
};
|
||||
const invoke = () =>
|
||||
operation === 'diff'
|
||||
? api.diff('Hello', input)
|
||||
: api[operation]('Hello', left, input);
|
||||
|
||||
expect(invoke).toThrow(RangeError);
|
||||
expect(invoke).toThrow(
|
||||
`Cursor position ${position} must be an integer between 0 and 3`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('accepts the start and Unicode scalar end positions', () => {
|
||||
const input = {
|
||||
text: 'a😀b',
|
||||
cursors: [
|
||||
{ id: 2, position: 0 },
|
||||
{ id: 3, position: 3 },
|
||||
],
|
||||
};
|
||||
if (operation === 'diff') {
|
||||
expect(api.undiff('Hello', api.diff('Hello', input))).toBe(input.text);
|
||||
} else {
|
||||
expect(api[operation]('Hello', 'Hello', input).cursors).toEqual(input.cursors);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(mergeOperations)('%s result conversion', (operation) => {
|
||||
it.each(['id', 'characterIndex'] as const)(
|
||||
'frees all result cursors if %s throws',
|
||||
(method) => {
|
||||
const error = new Error('cursor conversion failed');
|
||||
const spy = jest
|
||||
.spyOn(bindings.CursorPosition.prototype, method)
|
||||
.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
expect(() =>
|
||||
api[operation]('Hello', left, {
|
||||
text: 'Hi world',
|
||||
cursors: [{ id: 2, position: 1 }],
|
||||
})
|
||||
).toThrow(error);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['text', 'cursors'] as const)('frees the result if %s throws', (method) => {
|
||||
const error = new Error('result conversion failed');
|
||||
const prototype =
|
||||
operation === 'reconcile'
|
||||
? bindings.TextWithCursors.prototype
|
||||
: bindings.TextWithCursorsAndHistory.prototype;
|
||||
const spy = jest.spyOn(prototype, method).mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
expect(() => api[operation]('Hello', left, 'Hi world')).toThrow(error);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['text', 'history'] as const)(
|
||||
'frees all history spans if %s throws',
|
||||
(method) => {
|
||||
const error = new Error('history conversion failed');
|
||||
const spy = jest
|
||||
.spyOn(bindings.SpanWithHistory.prototype, method)
|
||||
.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
expect(() => api.reconcileWithHistory('Hello', left, 'Hi world')).toThrow(error);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('frees the result if reading history throws', () => {
|
||||
const error = new Error('reading history failed');
|
||||
const spy = jest
|
||||
.spyOn(bindings.TextWithCursorsAndHistory.prototype, 'history')
|
||||
.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
expect(() => api.reconcileWithHistory('Hello', left, 'Hi world')).toThrow(error);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(backends)('diff and undiff are inverse [$name]', ({ api }) => {
|
||||
const { diff, undiff } = api;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from pathlib import Path
|
|||
|
||||
import pytest
|
||||
|
||||
from reconcile_text import diff, reconcile, reconcile_with_history, undiff
|
||||
from reconcile_text import TextWithCursors, diff, reconcile, reconcile_with_history, undiff
|
||||
|
||||
EXAMPLES_DIR = Path(__file__).resolve().parent.parent.parent / "examples"
|
||||
RESOURCES_DIR = Path(__file__).resolve().parent.parent.parent / "tests" / "resources"
|
||||
|
|
@ -14,6 +14,41 @@ RESOURCES_DIR = Path(__file__).resolve().parent.parent.parent / "tests" / "resou
|
|||
FILES = ["pride_and_prejudice.txt", "room_with_a_view.txt", "blns.txt"]
|
||||
|
||||
|
||||
class TestCursorPositions:
|
||||
@pytest.mark.parametrize("text", ["", "a", "é", "😀", "e\u0301"])
|
||||
def test_out_of_bounds(self, text: str) -> None:
|
||||
position = len(text) + 1
|
||||
invalid: TextWithCursors = {
|
||||
"text": text,
|
||||
"cursors": [{"id": 1, "position": position}],
|
||||
}
|
||||
message = f"Cursor position {position} exceeds text length {len(text)}"
|
||||
|
||||
for merge in (reconcile, reconcile_with_history):
|
||||
with pytest.raises(ValueError, match=message):
|
||||
merge(text, invalid, text)
|
||||
with pytest.raises(ValueError, match=message):
|
||||
merge(text, text, invalid)
|
||||
|
||||
with pytest.raises(ValueError, match=message):
|
||||
diff(text, invalid)
|
||||
|
||||
@pytest.mark.parametrize("text", ["", "a", "é", "😀", "e\u0301"])
|
||||
def test_boundary_positions(self, text: str) -> None:
|
||||
valid: TextWithCursors = {
|
||||
"text": text,
|
||||
"cursors": [{"id": 1, "position": 0}, {"id": 2, "position": len(text)}],
|
||||
}
|
||||
|
||||
for merge in (reconcile, reconcile_with_history):
|
||||
for left, right in ((valid, text), (text, valid)):
|
||||
result = merge(text, left, right)
|
||||
assert result["text"] == text
|
||||
assert result["cursors"] == valid["cursors"]
|
||||
|
||||
assert diff(text, valid) == diff(text, text)
|
||||
|
||||
|
||||
class TestReconcile:
|
||||
def test_basic_merge(self) -> None:
|
||||
result = reconcile("Hello", "Hello world", "Hi world")
|
||||
|
|
@ -122,6 +157,34 @@ class TestUndiff:
|
|||
|
||||
|
||||
class TestExamples:
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
b"line1\r\nline2\r\n",
|
||||
b"line1\nline2\n",
|
||||
b"line1\rline2\r",
|
||||
b"line1\r\nline2\nline3\rline4",
|
||||
],
|
||||
ids=["crlf", "lf", "cr", "mixed-no-final-newline"],
|
||||
)
|
||||
@pytest.mark.parametrize("write_output", [False, True], ids=["stdout", "file"])
|
||||
def test_merge_file_preserves_line_endings(
|
||||
self, tmp_path: Path, content: bytes, write_output: bool
|
||||
) -> None:
|
||||
inputs = [tmp_path / name for name in ("mine.txt", "base.txt", "theirs.txt")]
|
||||
for path in inputs:
|
||||
path.write_bytes(content)
|
||||
|
||||
args = [sys.executable, str(EXAMPLES_DIR / "merge_file.py"), *map(str, inputs)]
|
||||
output = tmp_path / "output.txt"
|
||||
if write_output:
|
||||
args.append(str(output))
|
||||
|
||||
result = subprocess.run(args, capture_output=True, check=True)
|
||||
|
||||
actual = output.read_bytes() if write_output else result.stdout
|
||||
assert actual == content
|
||||
|
||||
def test_merge_file_stdout(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "base.txt").write_text("Hello world")
|
||||
(tmp_path / "mine.txt").write_text("Hello beautiful world")
|
||||
|
|
|
|||
Loading…
Reference in a new issue