1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
|
#!/usr/bin/env python3
"""`slosh --dump-config`: every setting, with its default value.
Generated from the code rather than kept as a file on disk, because a
checked-in copy of the defaults is a second source of truth and drifts — ours
had already lost four theme colours before anyone noticed.
Two properties make it worth trusting, and both are cheap to check: what it
writes must parse back to exactly what it wrote, and every key the parser
knows must appear in it. The second is the one that catches "added a knob,
forgot to render it", which is the failure this whole approach exists to
prevent.
"""
import os
import re
import subprocess
import sys
import tempfile
from harness import BIN, Session, check, report
HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(HERE, "..", "src", "config.c")
def dump(config=None):
env = dict(os.environ)
env["SLOSH_CONFIG"] = config or "/nonexistent/slosh.kdl"
return subprocess.run(
[BIN, "--dump-config"], capture_output=True, text=True, env=env
).stdout
def test_it_dumps_something_that_looks_like_a_config():
text = dump()
check("it has content", len(text.splitlines()) > 100, str(len(text)))
for expect in ("gap ", "theme {", "states {", "keys {", "prefix "):
check(f"it has {expect!r}", expect in text, text[:200])
def test_it_parses_back_to_exactly_itself():
"""The round trip: dump, load that, dump again. If the two differ then one
of them is losing something, and this is the only way to find out which
without reading 180 lines every time."""
one = dump()
f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
f.write(one)
f.close()
two = dump(f.name)
check(
"a dump of a dump is the same dump",
one == two,
"\n".join(
l
for l in __import__("difflib").unified_diff(
one.splitlines(), two.splitlines(), "first", "second", lineterm=""
)
)[:1500],
)
os.unlink(f.name)
def test_a_value_with_a_quote_in_it_is_written_escaped():
"""`\\` and `"` both end or escape a KDL string, so a value carrying either has
to be written escaped or the dump stops being a file that loads. Not
hypothetical: the default `word_separators` opens with a quote, because the
quote is the character a double-click most wants to stop at.
The round-trip tests above would catch a *default* that broke this. This one
says which line, so the failure names the setting instead of the whole file."""
dumped = dump()
line = [l for l in dumped.splitlines() if l.startswith("word_separators ")]
check("word_separators is rendered", len(line) == 1, str(line))
if line:
value = line[0].split(None, 1)[1]
check(
"its quote is escaped", value.startswith('"' + chr(92) + '"'), repr(value)
)
check(
"and the line is one KDL string, not two",
value.count('"') - value.count(chr(92) + '"') == 2,
repr(value),
)
def test_every_key_the_parser_knows_is_in_it():
"""The drift check. `kdl_child(root, "x")` is how config.c reads a
top-level setting, so that list is the parser's own vocabulary."""
src = open(SRC).read()
keys = set(re.findall(r'kdl_child\(root, "([a-z_]+)"\)', src))
# These are containers whose contents are rendered, not scalars.
keys -= {"keys"}
text = dump()
missing = sorted(
k for k in keys if not re.search(r'^(// )?%s[ {"]' % re.escape(k), text, re.M)
)
check(
f"all {len(keys)} settings are rendered",
not missing,
"missing: " + ", ".join(missing),
)
def test_every_theme_colour_is_in_it():
src = open(SRC).read()
colours = set(re.findall(r'\{"([a-z_]+)", offsetof\(config_t', src))
text = dump()
# A colour that is *unset* is the terminal's own, and a theme block has no
# spelling for that -- parse_color takes #rrggbb and nothing else. Those
# are written commented out, so the dump still names the key while loading
# it back does not pin "none" to black. Same tolerance the settings check
# above already has.
missing = sorted(
c
for c in colours
if not re.search(r"^\s*(// )?%s[ \"]" % re.escape(c), text, re.M)
)
check(
f"all {len(colours)} colours are rendered",
not missing,
"missing: " + ", ".join(missing),
)
check("and there are plenty of them", len(colours) > 40, str(len(colours)))
def test_the_dump_is_a_working_config():
"""Not just parseable: a session started with it behaves like one started
with nothing, because that is what "these are the defaults" means."""
f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
f.write(dump())
f.close()
sh = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; read x']
with Session(sh, cols=70, rows=14, config=f.name) as s:
s.settle(20)
with_dump = s.snapshot().screen()
with Session(sh, cols=70, rows=14) as s:
s.settle(20)
with_none = s.snapshot().screen()
check(
"a session run from the dump looks like one run from nothing",
with_dump == with_none,
"\n--- from the dump\n%s\n--- from nothing\n%s" % (with_dump, with_none),
)
# ...and it loads without a word of complaint, which the screen alone could
# not tell you: for a long time the dump wrote every chord in the
# cheatsheet's notation (`prefix "C-a"`, `bind "S-←"`) and the parser only
# spoke the config's, so eleven of its own lines came back as complaints and
# the session ran on the defaults those lines were describing. Two sessions
# looked identical for the worst possible reason.
with Session(sh, cols=70, rows=14, config=f.name) as s:
s.settle(20)
reply = s.api("reload")
check(
"and it loads with nothing to complain about",
reply.get("ok") and "warning" not in reply,
str(reply),
)
os.unlink(f.name)
def test_edit_config_writes_it_when_there_is_none():
"""The reason it exists: C-a e on a fresh install opened an empty buffer,
which tells you nothing about what you can set."""
home = tempfile.mkdtemp(prefix="slosh-home-")
sh = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; read x']
# No config, and no ~/.config either: a fresh container has neither.
with Session(
sh,
cols=74,
rows=24,
env={"HOME": home, "EDITOR": "tail -f"},
config=os.path.join(home, ".config", "slosh", "config.kdl"),
) as s:
s.settle()
s.key("e")
# `tail -f` shows the *end* of the file, so wait for something that is
# actually on screen rather than for the first thing in the config.
snap = s.until_text("select-tab-9")
check(
"the editor pane has the config in it",
"select-tab-9" in snap.screen(),
snap.screen()[-400:],
)
path = os.path.join(home, ".config", "slosh", "config.kdl")
check("and it was written, directories and all", os.path.exists(path), path)
if os.path.exists(path):
check(
"with the real defaults in it",
"theme {" in open(path).read(),
open(path).read()[:200],
)
def test_it_never_overwrites_a_config_you_already_have():
home = tempfile.mkdtemp(prefix="slosh-home-")
path = os.path.join(home, "config.kdl")
open(path, "w").write("// mine\ngap 3\n")
sh = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; read x']
with Session(
sh, cols=74, rows=24, config=path, env={"HOME": home, "EDITOR": "tail -f"}
) as s:
s.settle()
s.key("e")
s.settle(60)
check(
"the file is untouched",
open(path).read() == "// mine\ngap 3\n",
open(path).read(),
)
for name, fn in sorted(list(globals().items())):
if name.startswith("test_"):
fn()
sys.exit(report())
|