initial commit
This commit is contained in:
7
tests/conftest.py
Normal file
7
tests/conftest.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# tests import the package directly; sublime/sublime_plugin come from tests/stubs
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(HERE))
|
||||
sys.path.insert(0, os.path.join(HERE, "stubs"))
|
||||
223
tests/stubs/sublime.py
Normal file
223
tests/stubs/sublime.py
Normal file
@@ -0,0 +1,223 @@
|
||||
""" Minimal sublime API stub so the plugin modules import and header/regions logic runs offline """
|
||||
|
||||
OP_EQUAL = 0
|
||||
OP_NOT_EQUAL = 1
|
||||
DRAW_NO_OUTLINE = 1
|
||||
DRAW_NO_FILL = 2
|
||||
LAYOUT_BLOCK = 2
|
||||
ENCODED_POSITION = 1
|
||||
KIND_ID_COLOR_REDISH = 1
|
||||
KIND_ID_COLOR_ORANGISH = 2
|
||||
KIND_ID_COLOR_YELLOWISH = 3
|
||||
KIND_ID_COLOR_GREENISH = 4
|
||||
KIND_ID_COLOR_CYANISH = 5
|
||||
KIND_ID_COLOR_BLUISH = 6
|
||||
KIND_ID_COLOR_PURPLISH = 7
|
||||
KIND_ID_COLOR_PINKISH = 8
|
||||
KIND_AMBIGUOUS = (0, "", "")
|
||||
|
||||
|
||||
class Region:
|
||||
def __init__(self, a, b=None):
|
||||
self.a = a
|
||||
self.b = a if b is None else b
|
||||
|
||||
def begin(self):
|
||||
return min(self.a, self.b)
|
||||
|
||||
def end(self):
|
||||
return max(self.a, self.b)
|
||||
|
||||
def __eq__(self, other):
|
||||
return (self.a, self.b) == (other.a, other.b)
|
||||
|
||||
def __repr__(self):
|
||||
return "Region({}, {})".format(self.a, self.b)
|
||||
|
||||
|
||||
class Settings(dict):
|
||||
def get(self, key, default=None):
|
||||
return dict.get(self, key, default)
|
||||
|
||||
def set(self, key, value):
|
||||
self[key] = value
|
||||
|
||||
def add_on_change(self, tag, callback):
|
||||
pass
|
||||
|
||||
|
||||
_settings = {}
|
||||
|
||||
|
||||
def load_settings(name):
|
||||
return _settings.setdefault(name, Settings())
|
||||
|
||||
|
||||
def set_timeout(fn, delay=0):
|
||||
fn()
|
||||
|
||||
|
||||
def set_timeout_async(fn, delay=0):
|
||||
fn()
|
||||
|
||||
|
||||
def status_message(text):
|
||||
pass
|
||||
|
||||
|
||||
def packages_path():
|
||||
return "/tmp/Packages"
|
||||
|
||||
|
||||
def windows():
|
||||
return []
|
||||
|
||||
|
||||
class QuickPanelItem:
|
||||
def __init__(self, trigger, details="", annotation="", kind=KIND_AMBIGUOUS):
|
||||
self.trigger, self.details, self.annotation, self.kind = trigger, details, annotation, kind
|
||||
|
||||
|
||||
class Phantom:
|
||||
def __init__(self, region, content, layout, on_navigate=None):
|
||||
self.region, self.content, self.layout, self.on_navigate = region, content, layout, on_navigate
|
||||
|
||||
|
||||
class Window:
|
||||
"""Captures the quick panel so tests can drive its callbacks"""
|
||||
|
||||
def __init__(self):
|
||||
self.panel = None
|
||||
self.commands = []
|
||||
|
||||
def focus_view(self, view):
|
||||
pass
|
||||
|
||||
def run_command(self, name, args=None):
|
||||
self.commands.append((name, args))
|
||||
|
||||
def show_quick_panel(self, items, on_select, flags=0, selected=-1, on_highlight=None, placeholder=None):
|
||||
self.panel = (items, on_select, selected, on_highlight, placeholder)
|
||||
|
||||
|
||||
class View:
|
||||
"""Text buffer with just the calls render.py makes"""
|
||||
|
||||
def __init__(self, text=""):
|
||||
self.text = text
|
||||
self.phantoms = {}
|
||||
self.settings_ = Settings()
|
||||
self.regions = {}
|
||||
self.syntax = None
|
||||
self.read_only = True
|
||||
self.name_ = ""
|
||||
self.sel_ = []
|
||||
self.status = {}
|
||||
|
||||
def id(self):
|
||||
return 1
|
||||
|
||||
def settings(self):
|
||||
return self.settings_
|
||||
|
||||
def size(self):
|
||||
return len(self.text)
|
||||
|
||||
def replace(self, edit, region, text):
|
||||
self.text = self.text[: region.begin()] + text + self.text[region.end() :]
|
||||
|
||||
def run_command(self, name, args=None):
|
||||
if name == "vdiff_replace":
|
||||
self.replace(None, Region(0, self.size()), args["text"])
|
||||
|
||||
def set_read_only(self, flag):
|
||||
self.read_only = flag
|
||||
|
||||
def is_read_only(self):
|
||||
return self.read_only
|
||||
|
||||
def assign_syntax(self, path):
|
||||
self.syntax = path
|
||||
|
||||
def add_regions(self, key, regions, scope="", icon="", flags=0):
|
||||
self.regions[key] = (list(regions), scope, icon, flags)
|
||||
|
||||
def erase_regions(self, key):
|
||||
self.regions.pop(key, None)
|
||||
|
||||
def text_point(self, row, col):
|
||||
lines = self.text.split("\n")
|
||||
return sum(len(l) + 1 for l in lines[:row]) + col
|
||||
|
||||
def rowcol(self, point):
|
||||
before = self.text[:point]
|
||||
row = before.count("\n")
|
||||
return row, len(before) - (before.rfind("\n") + 1)
|
||||
|
||||
def full_line(self, point):
|
||||
start = self.text.rfind("\n", 0, point) + 1
|
||||
end = self.text.find("\n", point)
|
||||
end = len(self.text) if end == -1 else end + 1
|
||||
return Region(start, end)
|
||||
|
||||
def erase_phantoms(self, key):
|
||||
self.phantoms.pop(key, None)
|
||||
|
||||
def add_phantom(self, key, region, content, layout, on_navigate=None):
|
||||
self.phantoms.setdefault(key, []).append(Phantom(region, content, layout, on_navigate))
|
||||
return len(self.phantoms[key])
|
||||
|
||||
def set_name(self, name):
|
||||
self.name_ = name
|
||||
|
||||
def name(self):
|
||||
return self.name_
|
||||
|
||||
def set_scratch(self, flag):
|
||||
pass
|
||||
|
||||
def set_status(self, key, value):
|
||||
self.status[key] = value
|
||||
|
||||
def set_viewport_position(self, xy, animate=True):
|
||||
pass
|
||||
|
||||
def show_at_center(self, point):
|
||||
pass
|
||||
|
||||
def style_for_scope(self, scope):
|
||||
return {"foreground": "#ffffff", "background": "#333333"}
|
||||
|
||||
def is_valid(self):
|
||||
return True
|
||||
|
||||
class _Sel(list):
|
||||
def clear(self):
|
||||
del self[:]
|
||||
|
||||
def add(self, region):
|
||||
self.append(region)
|
||||
|
||||
def sel(self):
|
||||
if not isinstance(self.sel_, View._Sel):
|
||||
self.sel_ = View._Sel(self.sel_)
|
||||
return self.sel_
|
||||
|
||||
|
||||
def find_syntax_for_file(path, first_line=""):
|
||||
class Syntax:
|
||||
pass
|
||||
|
||||
syntax = Syntax()
|
||||
syntax.path = "Packages/Python/Python.sublime-syntax" if path.endswith(".py") else "Packages/Text/Plain text.tmLanguage"
|
||||
return syntax
|
||||
|
||||
|
||||
def ui_info():
|
||||
return {"system": {"style": "dark"}, "color_scheme": {"value": "Mariana.sublime-color-scheme", "resolved_value": "Packages/Color Scheme - Default/Mariana.sublime-color-scheme"}}
|
||||
|
||||
|
||||
def decode_value(text):
|
||||
import json
|
||||
|
||||
return json.loads(text)
|
||||
12
tests/stubs/sublime_plugin.py
Normal file
12
tests/stubs/sublime_plugin.py
Normal file
@@ -0,0 +1,12 @@
|
||||
class TextCommand:
|
||||
def __init__(self, view):
|
||||
self.view = view
|
||||
|
||||
|
||||
class WindowCommand:
|
||||
def __init__(self, window):
|
||||
self.window = window
|
||||
|
||||
|
||||
class EventListener:
|
||||
pass
|
||||
126
tests/test_commands.py
Normal file
126
tests/test_commands.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import sublime # the stub, via conftest
|
||||
|
||||
from vdiff import commands, git, state
|
||||
|
||||
|
||||
def change(path, status="M"):
|
||||
return git.FileChange(path, status, False, 1, 1, None)
|
||||
|
||||
|
||||
def flush_previews():
|
||||
"""The stub runs set_timeout synchronously, so previews are already shown"""
|
||||
|
||||
|
||||
def test_esc_restores_previous_file_and_mode(monkeypatch):
|
||||
shown = []
|
||||
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append((st.sha, c.path)))
|
||||
view = sublime.View()
|
||||
st = state.ViewState("/repo")
|
||||
st.change = change("render.py")
|
||||
st.files = [st.change]
|
||||
|
||||
# switch to a commit, as open_commit does; the panel gets the pre-switch snapshot
|
||||
kept = st.enter("abc", git.CommitInfo("abc", "abc1234", "A", "d", "s", ""))
|
||||
assert st.change is None and kept.change.path == "render.py"
|
||||
files = [change("notes.txt")]
|
||||
window = sublime.Window()
|
||||
commands.show_files_panel(window, view, st, files, kept, placeholder="x")
|
||||
items, on_select, selected, on_highlight, _ = window.panel
|
||||
assert [i.trigger for i in items] == ["notes.txt"] and selected == 0
|
||||
|
||||
on_highlight(0)
|
||||
assert shown[-1] == ("abc", "notes.txt")
|
||||
|
||||
on_select(-1)
|
||||
assert st.sha is None and st.commit is None and st.change.path == "render.py" and st.files == [st.change]
|
||||
assert shown[-1] == (None, "render.py")
|
||||
|
||||
|
||||
def test_esc_with_nothing_kept_keeps_preview(monkeypatch):
|
||||
shown = []
|
||||
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append(c.path))
|
||||
st = state.ViewState("/repo")
|
||||
window = sublime.Window()
|
||||
commands.show_files_panel(window, sublime.View(), st, [change("a.py"), change("b.py")], st.kept())
|
||||
_, on_select, _, on_highlight, _ = window.panel
|
||||
on_highlight(1)
|
||||
on_select(-1)
|
||||
assert shown == ["b.py"]
|
||||
|
||||
|
||||
def test_enter_skips_refetch_when_preview_is_current(monkeypatch):
|
||||
shown = []
|
||||
|
||||
def fake_show(view, st, c):
|
||||
st.token += 1
|
||||
st.change = c
|
||||
st.rendered_token = st.token
|
||||
shown.append(c.path)
|
||||
|
||||
monkeypatch.setattr(commands, "show_file", fake_show)
|
||||
st = state.ViewState("/repo")
|
||||
window = sublime.Window()
|
||||
files = [change("a.py"), change("b.py")]
|
||||
commands.show_files_panel(window, sublime.View(), st, files, st.kept())
|
||||
_, on_select, _, on_highlight, _ = window.panel
|
||||
on_highlight(1)
|
||||
on_select(1)
|
||||
assert shown == ["b.py"] and st.files[1].path == "b.py"
|
||||
on_select(0)
|
||||
assert shown == ["b.py", "a.py"]
|
||||
|
||||
|
||||
def test_on_cancel_goes_back_to_commit_list(monkeypatch):
|
||||
monkeypatch.setattr(commands, "show_file", lambda view, st, c: None)
|
||||
st = state.ViewState("/repo")
|
||||
kept = st.enter("abc", None)
|
||||
window = sublime.Window()
|
||||
back = []
|
||||
commands.show_files_panel(window, sublime.View(), st, [change("a.py")], kept, on_cancel=lambda: back.append(1))
|
||||
window.panel[1](-1)
|
||||
assert back == [1] and st.sha is None
|
||||
|
||||
|
||||
def test_step_change_uses_blocks():
|
||||
view = sublime.View()
|
||||
st = state.ViewState("/repo")
|
||||
st.blocks = [(2, 4), (7, 8)]
|
||||
view.text = "\n" + "\n".join("l{}".format(i) for i in range(10))
|
||||
view.sel().add(sublime.Region(commands.render.index_point(view, 0)))
|
||||
commands.ACTIONS["next_change"](view, None, st)
|
||||
assert commands.render.caret_index(view) == 2
|
||||
commands.ACTIONS["next_change"](view, None, st)
|
||||
assert commands.render.caret_index(view) == 7
|
||||
commands.ACTIONS["prev_change"](view, None, st)
|
||||
assert commands.render.caret_index(view) == 2
|
||||
|
||||
|
||||
def test_file_items_annotations():
|
||||
items = commands.file_items([git.FileChange("x.py", "?", False, 3, 0, None), git.FileChange("i.png", "M", True, None, None, None)])
|
||||
assert items[0].annotation == "-0 +3" and items[0].details == "untracked" and items[0].kind[1] == "?"
|
||||
assert items[1].annotation == "binary" and items[1].details == "modified, staged"
|
||||
|
||||
|
||||
def test_git_actions_refuse_commit_mode(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(commands, "run_async", lambda work, done: calls.append(work))
|
||||
st = state.ViewState("/repo")
|
||||
st.enter("abc", None)
|
||||
st.change = change("a.py")
|
||||
commands.ACTIONS["stage"](sublime.View(), None, st)
|
||||
assert calls == []
|
||||
st.enter()
|
||||
st.change = change("a.py")
|
||||
commands.ACTIONS["stage"](sublime.View(), None, st)
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_run_async_reports_git_error(monkeypatch):
|
||||
messages = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda m: messages.append(m))
|
||||
|
||||
def boom():
|
||||
raise git.GitError("fatal: bad revision 'nope'\nmore", 128)
|
||||
|
||||
commands.run_async(boom, lambda r: messages.append("done"))
|
||||
assert messages == ["vDiff: fatal: bad revision 'nope'"]
|
||||
148
tests/test_git.py
Normal file
148
tests/test_git.py
Normal file
@@ -0,0 +1,148 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from vdiff import git, parse
|
||||
|
||||
|
||||
def sh(repo, *args):
|
||||
subprocess.run(["git"] + list(args), cwd=repo, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
|
||||
def write(repo, name, text):
|
||||
path = os.path.join(repo, name)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path):
|
||||
repo = str(tmp_path)
|
||||
sh(repo, "init", "-q", "-b", "main")
|
||||
sh(repo, "config", "user.email", "t@example.com")
|
||||
sh(repo, "config", "user.name", "Test")
|
||||
write(repo, "a.py", "def f():\n return 1\n\n\ndef g():\n return 2\n")
|
||||
write(repo, "b.txt", "keep\n")
|
||||
write(repo, "gone.py", "bye\n")
|
||||
sh(repo, "add", ".")
|
||||
sh(repo, "commit", "-q", "-m", "initial")
|
||||
return repo
|
||||
|
||||
|
||||
def test_repo_root(repo):
|
||||
assert git.repo_root(os.path.join(repo, "a.py")) == os.path.realpath(repo)
|
||||
assert git.repo_root(repo) == os.path.realpath(repo)
|
||||
assert git.repo_root("/") is None
|
||||
|
||||
|
||||
def test_changed_files_all_kinds(repo):
|
||||
write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n") # unstaged M
|
||||
write(repo, "b.txt", "keep\nmore\n")
|
||||
sh(repo, "add", "b.txt") # staged M
|
||||
os.remove(os.path.join(repo, "gone.py")) # unstaged D
|
||||
write(repo, "sub/new.py", "x = 1\ny = 2") # untracked, no trailing newline
|
||||
changes = {c.path: c for c in git.changed_files(repo)}
|
||||
assert set(changes) == {"a.py", "b.txt", "gone.py", "sub/new.py"}
|
||||
assert changes["a.py"] == git.FileChange("a.py", "M", False, 1, 1, None)
|
||||
assert changes["b.txt"] == git.FileChange("b.txt", "M", True, 1, 0, None)
|
||||
assert changes["gone.py"].status == "D" and changes["gone.py"].deletions == 1
|
||||
assert changes["sub/new.py"] == git.FileChange("sub/new.py", "?", False, 2, 0, None)
|
||||
|
||||
|
||||
def test_full_diff_working_tree(repo):
|
||||
write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n")
|
||||
diff = parse.parse_unified(git.full_diff(repo, "a.py"))
|
||||
assert [l.kind for l in diff.lines] == [" ", "-", "+", " ", " ", " ", " "]
|
||||
assert diff.lines[2].text == " return 10"
|
||||
write(repo, "n.py", "one\ntwo\n")
|
||||
new = parse.parse_unified(git.full_diff(repo, "n.py", untracked=True))
|
||||
assert [l.text for l in new.lines] == ["one", "two"] and all(l.kind == "+" for l in new.lines)
|
||||
|
||||
|
||||
def test_stage_unstage_discard(repo):
|
||||
write(repo, "a.py", "changed\n")
|
||||
write(repo, "n.py", "new\n")
|
||||
changes = {c.path: c for c in git.changed_files(repo)}
|
||||
git.stage(repo, changes["a.py"])
|
||||
changes = {c.path: c for c in git.changed_files(repo)}
|
||||
assert changes["a.py"].staged
|
||||
git.unstage(repo, changes["a.py"])
|
||||
changes = {c.path: c for c in git.changed_files(repo)}
|
||||
assert not changes["a.py"].staged
|
||||
git.discard(repo, changes["a.py"])
|
||||
git.discard(repo, git.FileChange("n.py", "?", False, 1, 0, None))
|
||||
assert git.changed_files(repo) == []
|
||||
assert not os.path.exists(os.path.join(repo, "n.py"))
|
||||
# staged new file: discard removes it from index and disk
|
||||
write(repo, "s.py", "s\n")
|
||||
git.stage(repo, git.FileChange("s.py", "?", False, 1, 0, None))
|
||||
change = git.changed_files(repo)[0]
|
||||
assert change.status == "A"
|
||||
git.discard(repo, change)
|
||||
assert git.changed_files(repo) == []
|
||||
|
||||
|
||||
def test_commits(repo):
|
||||
write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n")
|
||||
write(repo, "c.py", "c\n")
|
||||
os.remove(os.path.join(repo, "gone.py"))
|
||||
sh(repo, "add", "-A")
|
||||
sh(repo, "commit", "-q", "-m", "second\n\nbody text")
|
||||
commits = git.recent_commits(repo, 10)
|
||||
assert [c.subject for c in commits] == ["second", "initial"]
|
||||
info, files = git.commit_with_files(repo, commits[0].sha)
|
||||
assert info.subject == "second" and info.body.strip() == "body text" and info.author == "Test"
|
||||
assert info.sha == commits[0].sha and len(info.date) == 16
|
||||
files = {c.path: c for c in files}
|
||||
assert files["a.py"] == git.FileChange("a.py", "M", False, 1, 1, None)
|
||||
assert files["c.py"].status == "A" and files["c.py"].insertions == 1
|
||||
assert files["gone.py"].status == "D" and files["gone.py"].deletions == 1
|
||||
diff = parse.parse_unified(git.full_diff(repo, "a.py", sha=commits[0].sha))
|
||||
assert [l.kind for l in diff.lines] == [" ", "-", "+", " ", " ", " ", " "]
|
||||
# root commit diffs against the empty tree
|
||||
_, root_files = git.commit_with_files(repo, commits[1].sha)
|
||||
assert {c.path: c.status for c in root_files} == {"a.py": "A", "b.txt": "A", "gone.py": "A"}
|
||||
# the short form resolves too (used by "review HEAD")
|
||||
info, _ = git.commit_with_files(repo, "HEAD")
|
||||
assert info.sha == commits[0].sha
|
||||
|
||||
|
||||
def test_no_head_repo(tmp_path):
|
||||
repo = str(tmp_path)
|
||||
sh(repo, "init", "-q")
|
||||
write(repo, "x.py", "x\n")
|
||||
assert git.recent_commits(repo, 5) == []
|
||||
changes = git.changed_files(repo)
|
||||
assert changes[0].status == "?"
|
||||
git.stage(repo, changes[0])
|
||||
changes = git.changed_files(repo)
|
||||
assert changes[0].status == "A" and changes[0].staged
|
||||
diff = parse.parse_unified(git.full_diff(repo, "x.py"))
|
||||
assert [l.kind for l in diff.lines] == ["+"]
|
||||
git.unstage(repo, changes[0])
|
||||
assert git.changed_files(repo)[0].status == "?"
|
||||
|
||||
|
||||
def test_rename_in_commit(repo):
|
||||
sh(repo, "mv", "b.txt", "moved.txt")
|
||||
sh(repo, "commit", "-q", "-m", "rename")
|
||||
_, files = git.commit_with_files(repo, "HEAD")
|
||||
assert files == [git.FileChange("moved.txt", "R", False, 0, 0, "b.txt")]
|
||||
|
||||
|
||||
def test_git_error_message(repo):
|
||||
with pytest.raises(git.GitError) as info:
|
||||
git.run(repo, ["rev-parse", "--verify", "nope"])
|
||||
assert "\n" not in str(info.value) and info.value.stderr
|
||||
|
||||
|
||||
def test_count_lines(tmp_path):
|
||||
p = str(tmp_path / "f")
|
||||
open(p, "wb").write(b"a\n" * 3000000)
|
||||
assert git._count_lines(p) == 3000000
|
||||
open(p, "wb").write(b"x\0y")
|
||||
assert git._count_lines(p) is None
|
||||
open(p, "wb").write(b"")
|
||||
assert git._count_lines(p) == 0
|
||||
117
tests/test_parse.py
Normal file
117
tests/test_parse.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from vdiff import parse
|
||||
|
||||
TWO_HUNKS = """diff --git a/x.py b/x.py
|
||||
index 111..222 100644
|
||||
--- a/x.py
|
||||
+++ b/x.py
|
||||
@@ -1,3 +1,3 @@
|
||||
a
|
||||
-b
|
||||
+B
|
||||
c
|
||||
@@ -10,2 +10,3 @@
|
||||
j
|
||||
+k
|
||||
l
|
||||
"""
|
||||
|
||||
NO_NEWLINE = """--- a/t.txt
|
||||
+++ b/t.txt
|
||||
@@ -1 +1 @@
|
||||
-old
|
||||
\\ No newline at end of file
|
||||
+new
|
||||
\\ No newline at end of file
|
||||
"""
|
||||
|
||||
UNTRACKED = """diff --git a/dev/null b/n.py
|
||||
new file mode 100644
|
||||
--- /dev/null
|
||||
+++ b/n.py
|
||||
@@ -0,0 +1,2 @@
|
||||
+one
|
||||
+two
|
||||
"""
|
||||
|
||||
DELETED = """--- a/gone.py
|
||||
+++ /dev/null
|
||||
@@ -1,2 +0,0 @@
|
||||
-one
|
||||
-two
|
||||
"""
|
||||
|
||||
BINARY = """diff --git a/i.png b/i.png
|
||||
index 111..222 100644
|
||||
Binary files a/i.png and b/i.png differ
|
||||
"""
|
||||
|
||||
EMPTY_CONTEXT = """--- a/e.py
|
||||
+++ b/e.py
|
||||
@@ -1,3 +1,3 @@
|
||||
x
|
||||
|
||||
-\ty
|
||||
+\tz
|
||||
"""
|
||||
|
||||
|
||||
def test_two_hunks_numbering():
|
||||
diff = parse.parse_unified(TWO_HUNKS)
|
||||
kinds = [l.kind for l in diff.lines]
|
||||
assert kinds == [" ", "-", "+", " ", " ", "+", " "]
|
||||
assert diff.lines[1] == parse.Line("-", 2, None, "b")
|
||||
assert diff.lines[2] == parse.Line("+", None, 2, "B")
|
||||
assert diff.lines[3] == parse.Line(" ", 3, 3, "c")
|
||||
assert diff.lines[4] == parse.Line(" ", 10, 10, "j")
|
||||
assert diff.lines[5] == parse.Line("+", None, 11, "k")
|
||||
assert diff.lines[6] == parse.Line(" ", 11, 12, "l")
|
||||
assert not diff.binary
|
||||
assert parse.counts(diff.lines) == (2, 1)
|
||||
|
||||
|
||||
def test_no_newline_marker_ignored():
|
||||
diff = parse.parse_unified(NO_NEWLINE)
|
||||
assert [l.text for l in diff.lines] == ["old", "new"]
|
||||
|
||||
|
||||
def test_untracked_and_deleted_files():
|
||||
new = parse.parse_unified(UNTRACKED)
|
||||
assert [l.new_no for l in new.lines] == [1, 2] and all(l.kind == "+" for l in new.lines)
|
||||
gone = parse.parse_unified(DELETED)
|
||||
assert all(l.kind == "-" for l in gone.lines) and [l.old_no for l in gone.lines] == [1, 2]
|
||||
|
||||
|
||||
def test_binary():
|
||||
diff = parse.parse_unified(BINARY)
|
||||
assert diff.binary and diff.lines == []
|
||||
|
||||
|
||||
def test_empty_context_line_and_tabs():
|
||||
diff = parse.parse_unified(EMPTY_CONTEXT)
|
||||
assert [l.text for l in diff.lines] == ["x", "", "\ty", "\tz"]
|
||||
|
||||
|
||||
def test_change_blocks_and_pairs():
|
||||
lines = parse.parse_unified(TWO_HUNKS).lines
|
||||
blocks = parse.change_blocks(lines)
|
||||
assert blocks == [(1, 3), (5, 6)]
|
||||
assert parse.paired_lines(lines, blocks[0]) == [(1, 2)]
|
||||
assert parse.paired_lines(lines, blocks[1]) == []
|
||||
|
||||
|
||||
def test_char_ranges():
|
||||
old_r, new_r = parse.char_ranges(" self.viewport = xy", " self.viewport = (x, y)")
|
||||
assert old_r == []
|
||||
assert new_r == [(20, 21), (22, 24), (25, 26)]
|
||||
assert parse.char_ranges("foo = bar", "foo = baz") == ([(8, 9)], [(8, 9)])
|
||||
assert parse.char_ranges("completely", "different!!!") == ([], [])
|
||||
assert parse.char_ranges("a" * 1500, "a" * 1499 + "b") == ([], [])
|
||||
|
||||
|
||||
def test_line_for_open():
|
||||
lines = parse.parse_unified(TWO_HUNKS).lines
|
||||
assert parse.line_for_open(lines, 1) == 2 # deleted row -> following line
|
||||
assert parse.line_for_open(lines, 6) == 12
|
||||
assert parse.line_for_open([], 5) == 1
|
||||
only_deleted = parse.parse_unified(DELETED).lines
|
||||
assert parse.line_for_open(only_deleted, 1) == 1
|
||||
112
tests/test_render.py
Normal file
112
tests/test_render.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import sublime # the stub, via conftest
|
||||
|
||||
from vdiff import commands, git, listeners, parse, render, state # noqa: F401
|
||||
|
||||
DIFF = """--- a/x.py
|
||||
+++ b/x.py
|
||||
@@ -1,6 +1,7 @@
|
||||
def f():
|
||||
- return 1
|
||||
-
|
||||
+ return 10
|
||||
+ # added
|
||||
+ # more
|
||||
|
||||
def g():
|
||||
pass
|
||||
"""
|
||||
|
||||
|
||||
def make_state(diff_text=DIFF):
|
||||
st = state.ViewState("/repo")
|
||||
st.change = git.FileChange("x.py", "M", False, 3, 2, None)
|
||||
diff = parse.parse_unified(diff_text)
|
||||
st.lines, st.binary = diff.lines, diff.binary
|
||||
st.blocks = parse.change_blocks(diff.lines)
|
||||
return st
|
||||
|
||||
|
||||
def header(view):
|
||||
return view.phantoms[render.PHANTOM_KEY][0].content
|
||||
|
||||
|
||||
def test_render_text_regions_and_header():
|
||||
view = sublime.View()
|
||||
render.init_view(view)
|
||||
st = make_state()
|
||||
render.render(view, st)
|
||||
|
||||
assert view.text.split("\n") == ["", "def f():", " return 1", "", " return 10", " # added", " # more", "", "def g():", " pass"]
|
||||
assert view.is_read_only()
|
||||
assert view.syntax.endswith("Python.sublime-syntax")
|
||||
|
||||
deleted, scope, _, flags = view.regions[render.KEY_DEL]
|
||||
assert scope == "diff.deleted" and flags == sublime.DRAW_NO_OUTLINE
|
||||
# rows 2-3 merged into one region, newline included; offsets match the view's own row arithmetic
|
||||
assert deleted == [sublime.Region(view.text_point(2, 0), view.text_point(4, 0))]
|
||||
inserted = view.regions[render.KEY_INS][0]
|
||||
assert inserted == [sublime.Region(view.text_point(4, 0), view.text_point(7, 0))]
|
||||
|
||||
marks = view.regions[render.KEY_INS_GUTTER]
|
||||
assert [r.a for r in marks[0]] == [view.text_point(4, 0), view.text_point(5, 0), view.text_point(6, 0)]
|
||||
assert marks[2] == "dot"
|
||||
|
||||
# 2 deleted vs 3 inserted lines: not a paired block, no char marks
|
||||
assert view.regions[render.KEY_INS_CHAR][0] == []
|
||||
|
||||
html = header(view)
|
||||
assert "x.py" in html and "-2" in html and "+3" in html and 'href="stage"' in html and "modified" in html
|
||||
assert len(view.phantoms[render.PHANTOM_KEY]) == 1
|
||||
assert view.name() == "⟐ x.py"
|
||||
assert view.settings().get(state.VIEW_DATA) == {"repo": "/repo", "sha": None, "path": "x.py", "status": "M", "staged": False}
|
||||
assert view.sel()[0].a == view.text_point(1, 0)
|
||||
|
||||
|
||||
def test_last_line_region_stops_at_buffer_end():
|
||||
view = sublime.View()
|
||||
render.init_view(view)
|
||||
st = make_state("--- a/y\n+++ b/y\n@@ -1,2 +1,2 @@\n a\n-b\n+c\n")
|
||||
render.render(view, st)
|
||||
assert view.text == "\na\nb\nc"
|
||||
assert view.regions[render.KEY_INS][0] == [sublime.Region(view.text_point(3, 0), len(view.text))]
|
||||
render.render(view, st) # re-render replaces, never duplicates, the header
|
||||
assert len(view.phantoms[render.PHANTOM_KEY]) == 1
|
||||
|
||||
|
||||
def test_char_marks_for_paired_lines():
|
||||
view = sublime.View()
|
||||
render.init_view(view)
|
||||
st = make_state("--- a/y\n+++ b/y\n@@ -1 +1 @@\n-foo = bar\n+foo = baz\n")
|
||||
render.render(view, st)
|
||||
assert view.regions[render.KEY_DEL_CHAR][0] == [sublime.Region(view.text_point(1, 8), view.text_point(1, 9))]
|
||||
assert view.regions[render.KEY_INS_CHAR][0] == [sublime.Region(view.text_point(2, 8), view.text_point(2, 9))]
|
||||
|
||||
|
||||
def test_commit_header_and_message():
|
||||
view = sublime.View()
|
||||
render.init_view(view)
|
||||
st = make_state()
|
||||
st.enter("abc", git.CommitInfo("abc", "abc1234", "Ann <x>", "2026-09-23 10:00", "Fix things", "details\nmore"))
|
||||
st.change = git.FileChange("x.py", "M", False, 3, 2, None)
|
||||
render.render(view, st)
|
||||
html = header(view)
|
||||
assert "abc1234" in html and "Ann <x>" in html and "Fix things" in html and "details" in html
|
||||
assert 'href="stage"' not in html and 'href="open"' in html
|
||||
|
||||
render.render_message(view, st, "nothing")
|
||||
assert view.text == "\nnothing" and st.change is None and view.name() == "vDiff"
|
||||
|
||||
|
||||
def test_binary_and_caret_index():
|
||||
view = sublime.View()
|
||||
render.init_view(view)
|
||||
st = make_state()
|
||||
st.binary, st.lines, st.blocks = True, [], []
|
||||
render.render(view, st)
|
||||
assert view.text == "\n(binary file)" and render.KEY_DEL not in view.regions
|
||||
view.sel().clear()
|
||||
view.sel().add(sublime.Region(render.index_point(view, 0)))
|
||||
assert render.caret_index(view) == 0
|
||||
view.sel().clear()
|
||||
view.sel().add(sublime.Region(0))
|
||||
assert render.caret_index(view) == -1
|
||||
Reference in New Issue
Block a user