syntax highlighting on changed lines, keeping the red/green background
This commit is contained in:
@@ -1,7 +1,25 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# 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"))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def scheme_override_sandbox(tmp_path, monkeypatch):
|
||||
"""Keep the colour-scheme override palette.py writes inside the test's own tmp_path
|
||||
|
||||
Both caches are per session and the stub hands every view the same id, so they would otherwise
|
||||
leak between tests and make every assertion about the override depend on test order.
|
||||
"""
|
||||
import sublime
|
||||
|
||||
from vdiff import palette, state
|
||||
|
||||
palette.reset()
|
||||
monkeypatch.setattr(state, "STATES", {})
|
||||
monkeypatch.setattr(sublime, "packages_path", lambda: str(tmp_path))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
""" Minimal sublime API stub so the plugin modules import and header/regions logic runs offline """
|
||||
import re
|
||||
|
||||
OP_EQUAL = 0
|
||||
OP_NOT_EQUAL = 1
|
||||
@@ -109,6 +110,29 @@ class Window:
|
||||
return self.input_view
|
||||
|
||||
|
||||
# a bundled scheme's styles: the diff.* and region.* scopes defined, the plugin's vdiff.* ones not.
|
||||
# Nothing here simulates sublime merging in a written override, so a test that wants the plugin's
|
||||
# scopes to resolve has to say so by adding them.
|
||||
DEFAULT_FG = "#ffffff"
|
||||
DEFAULT_STYLES = {
|
||||
"diff.deleted": {"foreground": DEFAULT_FG, "background": "#4a2b2b"},
|
||||
"diff.deleted.char": {"foreground": DEFAULT_FG, "background": "#6a3535"},
|
||||
"diff.inserted": {"foreground": DEFAULT_FG, "background": "#2b4a3a"},
|
||||
"diff.inserted.char": {"foreground": DEFAULT_FG, "background": "#356a4a"},
|
||||
"region.yellowish": {"foreground": "#e6db74", "background": "#4a4a2b"},
|
||||
"markup.deleted": {"foreground": "#d64646"},
|
||||
"markup.inserted": {"foreground": "#46d646"},
|
||||
# a few of palette.PALETTE_PROBES, so the palette probe finds a working set of colours
|
||||
"source": {"foreground": DEFAULT_FG},
|
||||
"comment": {"foreground": "#7c8595"},
|
||||
"string": {"foreground": "#99c794"},
|
||||
"keyword": {"foreground": "#c594c5"},
|
||||
"constant.numeric": {"foreground": "#f99157"},
|
||||
"entity.name.function": {"foreground": "#6699cc"},
|
||||
"punctuation.definition": {"foreground": "#5fb3b3"},
|
||||
}
|
||||
|
||||
|
||||
class View:
|
||||
"""Text buffer with just the calls render.py makes"""
|
||||
|
||||
@@ -125,6 +149,10 @@ class View:
|
||||
self.commands = []
|
||||
self.popup = None
|
||||
self.popup_navigate = None
|
||||
# what style_for_scope answers; a test reshapes it to model another colour scheme
|
||||
self.styles = {scope: dict(style) for scope, style in DEFAULT_STYLES.items()}
|
||||
# whether sublime has merged the override vdiff writes; it only does so on a restart
|
||||
self.override_loaded = False
|
||||
|
||||
def id(self):
|
||||
return 1
|
||||
@@ -211,8 +239,25 @@ class View:
|
||||
def em_width(self):
|
||||
return 8.0
|
||||
|
||||
def extract_tokens_with_scopes(self, region):
|
||||
"""One token per run of word characters and one per run of anything else, scoped by its shape
|
||||
|
||||
Enough shape for the colouring tests: real sublime returns the syntax's tokens, and all the
|
||||
painter needs is that adjacent tokens differ and each maps to a scope with a colour.
|
||||
"""
|
||||
tokens = []
|
||||
text = self.text[region.a:region.b]
|
||||
for match in re.finditer(r"\w+|\s+|[^\w\s]+", text):
|
||||
scope = "keyword" if match.group().isidentifier() else "punctuation.definition"
|
||||
tokens.append((Region(region.a + match.start(), region.a + match.end()), scope))
|
||||
return tokens
|
||||
|
||||
def style_for_scope(self, scope):
|
||||
return {"foreground": "#ffffff", "background": "#333333"}
|
||||
# a fresh dict per call, and no background for an unknown scope, like the real API
|
||||
if self.override_loaded and scope.startswith("vdiff.") and scope not in self.styles:
|
||||
# sublime has merged the override, so every rule vdiff wrote in it resolves
|
||||
return {"foreground": DEFAULT_FG, "background": "#333333"}
|
||||
return dict(self.styles.get(scope, {"foreground": DEFAULT_FG}))
|
||||
|
||||
def is_valid(self):
|
||||
return True
|
||||
|
||||
86
tests/test_palette.py
Normal file
86
tests/test_palette.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import json
|
||||
|
||||
import sublime # the stub, via conftest
|
||||
|
||||
from vdiff import palette
|
||||
|
||||
|
||||
def scheme_file(tmp_path):
|
||||
"""The override of whichever scheme the stub reports as active"""
|
||||
return tmp_path / "User" / palette.override_name(sublime.View())
|
||||
|
||||
|
||||
def override(tmp_path):
|
||||
"""The rules palette.py wrote into that override"""
|
||||
path = scheme_file(tmp_path)
|
||||
return json.loads(path.read_text())["rules"] if path.exists() else None
|
||||
|
||||
|
||||
def by_scope(tmp_path):
|
||||
return {rule["scope"]: rule for rule in override(tmp_path)}
|
||||
|
||||
|
||||
def test_a_scheme_with_diff_colours_needs_no_tint_rules(tmp_path):
|
||||
view = sublime.View()
|
||||
palette.ensure(view)
|
||||
|
||||
# the tints are the scheme's own scopes, so only the generated colour rules are ours to write
|
||||
assert not [r for r in override(tmp_path) if r["scope"].startswith("diff.")]
|
||||
|
||||
|
||||
def test_a_scheme_without_diff_colours_gets_our_tints(tmp_path):
|
||||
view = sublime.View()
|
||||
# all or nothing per pair: taking only the half a scheme has would put our hue next to its own
|
||||
del view.styles["diff.deleted.char"]
|
||||
del view.styles["diff.inserted"]
|
||||
del view.styles["diff.inserted.char"]
|
||||
palette.ensure(view)
|
||||
|
||||
rules = by_scope(tmp_path)
|
||||
assert rules[palette.SCOPE_INS]["background"] == palette.FALLBACK_TINTS[palette.SCOPE_INS]
|
||||
assert rules[palette.SCOPE_DEL_CHAR]["background"] == palette.FALLBACK_TINTS[palette.SCOPE_DEL_CHAR]
|
||||
# the one colour it does define is left to the scheme
|
||||
assert palette.SCOPE_DEL not in rules
|
||||
|
||||
|
||||
def test_every_palette_colour_gets_a_rule_per_background_kind(tmp_path):
|
||||
view = sublime.View()
|
||||
palette.ensure(view)
|
||||
|
||||
rules = by_scope(tmp_path)
|
||||
colours = {view.styles[scope]["foreground"] for scope in ("comment", "string", "keyword")}
|
||||
for colour in colours:
|
||||
scope = palette.scope_for("ins", colour)
|
||||
assert scope is not None
|
||||
# both properties, which is the whole point: the tint alone would flatten the text
|
||||
assert rules[scope] == {"scope": scope, "background": view.styles["diff.inserted"]["background"],
|
||||
"foreground": colour}
|
||||
for kind in palette.KINDS:
|
||||
assert palette.scope_for(kind, colour) in rules
|
||||
|
||||
|
||||
def test_a_colour_the_scheme_does_not_use_has_no_scope():
|
||||
palette.ensure(sublime.View())
|
||||
assert palette.scope_for("ins", "#123456") is None
|
||||
|
||||
|
||||
def test_the_override_is_written_once_per_scheme(tmp_path):
|
||||
view = sublime.View()
|
||||
palette.ensure(view)
|
||||
scheme_file(tmp_path).write_text(json.dumps({"rules": []}))
|
||||
|
||||
# sublime reloads the whole scheme every time we touch the file, so nothing must rewrite it
|
||||
palette.ensure(view)
|
||||
assert override(tmp_path) == []
|
||||
|
||||
|
||||
def test_a_hand_edited_override_is_never_clobbered(tmp_path):
|
||||
(tmp_path / "User").mkdir()
|
||||
path = tmp_path / "User" / "Mariana.sublime-color-scheme"
|
||||
# comments are legal in a .sublime-color-scheme but decode_value here is plain json
|
||||
path.write_text('{\n // mine\n "rules": []\n}')
|
||||
|
||||
palette.ensure(sublime.View())
|
||||
assert "// mine" in path.read_text()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import re
|
||||
|
||||
import sublime # the stub, via conftest
|
||||
|
||||
from vdiff import commands, git, listeners, parse, render, state # noqa: F401
|
||||
from vdiff import commands, git, listeners, palette, parse, render, state # noqa: F401
|
||||
|
||||
DIFF = """--- a/x.py
|
||||
+++ b/x.py
|
||||
@@ -34,8 +34,18 @@ def header(view):
|
||||
return view.phantoms[render.PHANTOM_KEY][0].content
|
||||
|
||||
|
||||
def with_loaded_override(view):
|
||||
"""Model a view whose scheme has picked up the override palette.py writes
|
||||
|
||||
The stub deliberately does not simulate that merge, just as sublime does not until it restarts,
|
||||
so a test wanting the generated colour scopes to resolve has to say so.
|
||||
"""
|
||||
view.override_loaded = True
|
||||
return view
|
||||
|
||||
|
||||
def test_render_text_regions_and_header():
|
||||
view = sublime.View()
|
||||
view = with_loaded_override(sublime.View())
|
||||
render.init_view(view)
|
||||
st = make_state()
|
||||
render.render(view, st)
|
||||
@@ -316,9 +326,99 @@ def test_flash_survives_a_re_render(monkeypatch):
|
||||
assert render.BELL_KEY in view.regions
|
||||
|
||||
|
||||
def test_bell_scope_falls_back_when_the_scheme_lacks_it(monkeypatch):
|
||||
view = sublime.View()
|
||||
def test_bell_scope_falls_back_when_the_scheme_lacks_it():
|
||||
view = with_loaded_override(sublime.View())
|
||||
assert render._bell_scope(view) == render.SCOPE_BELL
|
||||
monkeypatch.setattr(view, "style_for_scope", lambda scope: {"foreground": "#fff"})
|
||||
# an invisible bell is a silent failure, so fall back to a scope the plugin guarantees
|
||||
assert render._bell_scope(view) == render.SCOPE_INS
|
||||
del view.styles["region.yellowish"]
|
||||
# an invisible bell is a silent failure, so fall back to a scope that is resolving
|
||||
assert render._bell_scope(view) == palette.SCOPE_INS
|
||||
# ... and when the plugin's own scopes are not resolving either, to the scheme's
|
||||
view.override_loaded = False
|
||||
assert render._bell_scope(view) == "diff.inserted"
|
||||
|
||||
|
||||
def token_regions(view, kind, colour):
|
||||
"""The regions painted for one (background kind, colour) pair of the token layer"""
|
||||
scope = palette.scope_for(kind, colour)
|
||||
entry = view.regions.get(scope and scope.replace("vdiff.", "vdiff.tok.", 1))
|
||||
return entry[0] if entry else []
|
||||
|
||||
|
||||
def test_changed_lines_are_repainted_in_the_files_own_colours():
|
||||
view = with_loaded_override(sublime.View())
|
||||
render.init_view(view)
|
||||
render.render(view, make_state())
|
||||
|
||||
keyword = view.styles["keyword"]["foreground"]
|
||||
punctuation = view.styles["punctuation.definition"]["foreground"]
|
||||
# " return 10" is an inserted line: its words carry the keyword colour, its spaces and digits
|
||||
# are their own runs. Without this layer every one of them would be the tint's flat foreground.
|
||||
inserted = token_regions(view, "ins", keyword)
|
||||
assert sublime.Region(view.text_point(4, 4), view.text_point(4, 10)) in inserted
|
||||
# and a deleted line is painted from the deleted palette, not the inserted one
|
||||
assert sublime.Region(view.text_point(2, 4), view.text_point(2, 10)) in token_regions(view, "del", keyword)
|
||||
assert token_regions(view, "del", punctuation)
|
||||
|
||||
# the plain tint still covers the whole line underneath, so gaps and line ends stay coloured
|
||||
assert view.regions[render.KEY_INS][1] == palette.SCOPE_INS
|
||||
|
||||
|
||||
def test_token_runs_merge_but_stop_at_a_word_diff_range():
|
||||
view = with_loaded_override(sublime.View())
|
||||
render.init_view(view)
|
||||
render.render(view, make_state("--- a/y\n+++ b/y\n@@ -1 +1 @@\n-foo = bar\n+foo = baz\n"))
|
||||
|
||||
keyword = view.styles["keyword"]["foreground"]
|
||||
# only the changed character sits in the stronger tint; the rest of the word keeps the line tint
|
||||
assert token_regions(view, "ins.char", keyword) == [sublime.Region(view.text_point(2, 8), view.text_point(2, 9))]
|
||||
plain = token_regions(view, "ins", keyword)
|
||||
assert sublime.Region(view.text_point(2, 6), view.text_point(2, 8)) in plain
|
||||
assert sublime.Region(view.text_point(2, 0), view.text_point(2, 3)) in plain
|
||||
|
||||
|
||||
def test_token_layer_waits_for_the_scopes_to_resolve():
|
||||
view = sublime.View()
|
||||
render.init_view(view)
|
||||
render.render(view, make_state())
|
||||
|
||||
# sublime has not loaded the override yet, so the changed lines keep the scheme's flat tint and
|
||||
# not one token key is painted: an unknown scope would draw its text in sublime's marker green
|
||||
assert view.regions[render.KEY_DEL][1] == "diff.deleted"
|
||||
assert view.regions[render.KEY_INS][1] == "diff.inserted"
|
||||
assert not [key for key in view.regions if key.startswith("vdiff.tok.")]
|
||||
|
||||
|
||||
def test_token_layer_is_skipped_for_a_diff_too_big_to_read(monkeypatch):
|
||||
monkeypatch.setattr(render, "TOKEN_COLOUR_MAX_CHARS", 4)
|
||||
view = with_loaded_override(sublime.View())
|
||||
render.init_view(view)
|
||||
render.render(view, make_state())
|
||||
|
||||
assert not [key for key in view.regions if key.startswith("vdiff.tok.")]
|
||||
|
||||
|
||||
def test_token_regions_are_dropped_when_a_re_render_no_longer_needs_them():
|
||||
view = with_loaded_override(sublime.View())
|
||||
render.init_view(view)
|
||||
render.render(view, make_state())
|
||||
assert [key for key in view.regions if key.startswith("vdiff.tok.")]
|
||||
|
||||
render.render(view, make_state("--- a/y\n+++ b/y\n@@ -1,2 +1,2 @@\n a\n-b\n+c\n"))
|
||||
keyword = view.styles["keyword"]["foreground"]
|
||||
# the new diff paints far fewer runs; the keys the old one used must not linger
|
||||
assert token_regions(view, "ins", keyword) == [sublime.Region(view.text_point(3, 0), view.text_point(3, 1))]
|
||||
|
||||
|
||||
def test_restart_notice_when_the_override_cannot_take_effect_yet(monkeypatch):
|
||||
said = []
|
||||
monkeypatch.setattr(sublime, "status_message", said.append)
|
||||
view = sublime.View()
|
||||
render.init_view(view)
|
||||
render.render(view, make_state())
|
||||
|
||||
assert said == ["vDiff: " + render.NEEDS_RESTART]
|
||||
# said once per scheme, not once per render
|
||||
render.render(view, make_state())
|
||||
assert said == ["vDiff: " + render.NEEDS_RESTART]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user