@voxgig/sdkgen 4.2.8 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/voxgig-sdkgen +1 -1
- package/dist/helpers/naming.d.ts +2 -1
- package/dist/helpers/naming.js +50 -12
- package/dist/helpers/naming.js.map +1 -1
- package/dist/sdkgen.d.ts +2 -2
- package/dist/sdkgen.js +4 -3
- package/dist/sdkgen.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/project/.sdk/tm/go/test/custom_utility_test.go +103 -0
- package/project/.sdk/tm/go/test/feature_corpus_test.go +550 -0
- package/project/.sdk/tm/go/utility/make_options.go +7 -1
- package/project/.sdk/tm/go/utility/register.go +194 -0
- package/project/.sdk/tm/java/test/CustomUtilityTest.java +55 -0
- package/project/.sdk/tm/java/test/FeatureCorpusTest.java +463 -0
- package/project/.sdk/tm/java/utility/MakeOptions.java +11 -2
- package/project/.sdk/tm/java/utility/Register.java +91 -0
- package/project/.sdk/tm/js/test/feature/Corpus.test.js +285 -0
- package/project/.sdk/tm/perl/t/feature_corpus.t +345 -0
- package/project/.sdk/tm/perl/utility/make_options.pm +33 -1
- package/project/.sdk/tm/php/core/Context.php +3 -0
- package/project/.sdk/tm/php/core/Control.php +13 -0
- package/project/.sdk/tm/php/core/Error.php +12 -0
- package/project/.sdk/tm/php/test/FeatureCorpusTest.php +376 -0
- package/project/.sdk/tm/php/utility/MakeOptions.php +30 -1
- package/project/.sdk/tm/py/pkg/utility/make_options.py +42 -1
- package/project/.sdk/tm/py/test/test_feature_corpus.py +309 -0
- package/project/.sdk/tm/rb/test/feature_corpus_test.rb +281 -0
- package/project/.sdk/tm/rb/utility/make_options.rb +32 -1
- package/project/.sdk/tm/ts/test/feature/Corpus.test.ts +287 -0
- package/project/sdkgen-package.json +1 -1
- package/src/helpers/naming.ts +54 -12
- package/src/sdkgen.ts +2 -1
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# ProjectName SDK feature corpus test
|
|
2
|
+
#
|
|
3
|
+
# Feature behaviour, driven by the SHARED corpus.
|
|
4
|
+
#
|
|
5
|
+
# The same route test_primary_utility.py takes for the utilities:
|
|
6
|
+
# language-neutral cases in .sdk/test/test.json, executed against THIS
|
|
7
|
+
# generated SDK. The feature is the ordinary class, built by the generated
|
|
8
|
+
# config, installed by the generated constructor, and driven by a real entity
|
|
9
|
+
# operation. Not a miniature of the pipeline - that is what feature_harness.py
|
|
10
|
+
# does, and a miniature can only be as right as the miniature.
|
|
11
|
+
#
|
|
12
|
+
# Everything in a case is data. The one piece python writes for itself is
|
|
13
|
+
# turning scripted responses into a fetcher, through the documented
|
|
14
|
+
# `utility.fetcher` override.
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from projectname_sdk import ProjectNameSDK
|
|
23
|
+
|
|
24
|
+
_TEST_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
25
|
+
|
|
26
|
+
# Features with a corpus section. A name here with no section is a skip, not
|
|
27
|
+
# a failure: an SDK generated without the feature has nothing to run.
|
|
28
|
+
FEATURE_CORPUS_NAMES = ["cost"]
|
|
29
|
+
|
|
30
|
+
# The standard operation names, in the order the runner prefers them.
|
|
31
|
+
FEATURE_CORPUS_OPS = ["load", "list", "create", "update", "remove"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_corpus():
|
|
35
|
+
with open(os.path.join(_TEST_DIR, "../../.sdk/test/test.json"), "r") as f:
|
|
36
|
+
return json.loads(f.read())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _scripted_fetcher(res):
|
|
40
|
+
"""A scripted transport built from a case's `res` list.
|
|
41
|
+
|
|
42
|
+
Responses are consumed in order and the last one repeats, so a case that
|
|
43
|
+
does not care how many attempts happen need only declare one.
|
|
44
|
+
"""
|
|
45
|
+
state = {"n": -1}
|
|
46
|
+
|
|
47
|
+
def fetcher(ctx, fullurl, fetchdef):
|
|
48
|
+
state["n"] += 1
|
|
49
|
+
spec = {}
|
|
50
|
+
if res:
|
|
51
|
+
i = state["n"]
|
|
52
|
+
if i >= len(res):
|
|
53
|
+
i = len(res) - 1
|
|
54
|
+
spec = res[i] or {}
|
|
55
|
+
|
|
56
|
+
status = spec.get("status")
|
|
57
|
+
status = 200 if status is None else int(status)
|
|
58
|
+
body = spec.get("body")
|
|
59
|
+
body = {} if body is None else body
|
|
60
|
+
|
|
61
|
+
# The shape the real fetcher returns: a (response, err) PAIR, with the
|
|
62
|
+
# parsed body behind a `json` thunk and `body` as the raw string. A
|
|
63
|
+
# bare dict here unpacks as "too many values", and a script that only
|
|
64
|
+
# set `body` would look like an empty result - either reads as a
|
|
65
|
+
# feature defect rather than a mis-shaped script.
|
|
66
|
+
if spec.get("throw") is True:
|
|
67
|
+
return None, RuntimeError("scripted transport failure")
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
"status": status,
|
|
71
|
+
"statusText": "OK" if status < 400 else "ERR",
|
|
72
|
+
"headers": dict(spec.get("headers") or {}),
|
|
73
|
+
"json": (lambda: body),
|
|
74
|
+
"body": json.dumps(body),
|
|
75
|
+
}, None
|
|
76
|
+
|
|
77
|
+
return fetcher
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _client(kase):
|
|
81
|
+
"""Build a client the way a caller would.
|
|
82
|
+
|
|
83
|
+
ProjectNameSDK(...), not ProjectNameSDK.test(...): the `test` feature is
|
|
84
|
+
transport: 'base' and REPLACES the transport, so a client in test mode
|
|
85
|
+
would shadow the script.
|
|
86
|
+
"""
|
|
87
|
+
opts = {"utility": {"fetcher": _scripted_fetcher(kase.get("res"))}}
|
|
88
|
+
if kase.get("feature") is not None:
|
|
89
|
+
opts["feature"] = kase["feature"]
|
|
90
|
+
return ProjectNameSDK(opts)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _candidates(client):
|
|
94
|
+
"""Every operation this SDK declares, in a stable order.
|
|
95
|
+
|
|
96
|
+
The corpus cannot name an entity - it is shared by SDKs with none in
|
|
97
|
+
common - so the runner finds them here. An entity accessor is a
|
|
98
|
+
capitalised, single-optional-argument client method whose result answers
|
|
99
|
+
get_name().
|
|
100
|
+
"""
|
|
101
|
+
found = {}
|
|
102
|
+
for attr in dir(client):
|
|
103
|
+
if not attr[:1].isupper():
|
|
104
|
+
continue
|
|
105
|
+
acc = getattr(client, attr, None)
|
|
106
|
+
if not callable(acc):
|
|
107
|
+
continue
|
|
108
|
+
try:
|
|
109
|
+
ent = acc()
|
|
110
|
+
except Exception:
|
|
111
|
+
continue
|
|
112
|
+
getname = getattr(ent, "get_name", None)
|
|
113
|
+
if not callable(getname):
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
name = getname()
|
|
117
|
+
except Exception:
|
|
118
|
+
continue
|
|
119
|
+
if isinstance(name, str) and name != "":
|
|
120
|
+
found[name] = (attr, ent)
|
|
121
|
+
|
|
122
|
+
out = []
|
|
123
|
+
for name in sorted(found):
|
|
124
|
+
accessor, ent = found[name]
|
|
125
|
+
for opname in FEATURE_CORPUS_OPS:
|
|
126
|
+
if callable(getattr(ent, opname, None)):
|
|
127
|
+
out.append({
|
|
128
|
+
"key": name + "." + opname,
|
|
129
|
+
"accessor": accessor,
|
|
130
|
+
"op": opname,
|
|
131
|
+
})
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _invoke(client, op, ctrl):
|
|
136
|
+
ent = getattr(client, op["accessor"])()
|
|
137
|
+
return getattr(ent, op["op"])({}, ctrl)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _usable_ops(want):
|
|
141
|
+
"""Pick operations by DRIVING them.
|
|
142
|
+
|
|
143
|
+
An op is usable when it completes against a plain 200 with no feature
|
|
144
|
+
active. Declared operations are not all callable with no arguments (a
|
|
145
|
+
required path parameter, a body), and a case failing for that reason
|
|
146
|
+
would read as a feature defect.
|
|
147
|
+
"""
|
|
148
|
+
picked = []
|
|
149
|
+
for cand in _candidates(_client({})):
|
|
150
|
+
try:
|
|
151
|
+
_invoke(_client({}), cand, {})
|
|
152
|
+
except Exception:
|
|
153
|
+
continue
|
|
154
|
+
picked.append(cand)
|
|
155
|
+
if len(picked) >= want:
|
|
156
|
+
break
|
|
157
|
+
return picked
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _resolve(node, tokens):
|
|
161
|
+
"""Replace #OPn throughout a case, keys included."""
|
|
162
|
+
if isinstance(node, str):
|
|
163
|
+
out = node
|
|
164
|
+
for tok, val in tokens.items():
|
|
165
|
+
out = out.replace(tok, val)
|
|
166
|
+
return out
|
|
167
|
+
if isinstance(node, list):
|
|
168
|
+
return [_resolve(n, tokens) for n in node]
|
|
169
|
+
if isinstance(node, dict):
|
|
170
|
+
return {_resolve(k, tokens): _resolve(v, tokens) for k, v in node.items()}
|
|
171
|
+
return node
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _tokens_used(kase):
|
|
175
|
+
"""The highest #OPn a case mentions."""
|
|
176
|
+
found = re.findall(r"#OP(\d+)", json.dumps(kase))
|
|
177
|
+
return max([int(n) for n in found], default=0)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _member(actual, key):
|
|
181
|
+
if actual is None:
|
|
182
|
+
return (None, False)
|
|
183
|
+
if isinstance(actual, dict):
|
|
184
|
+
if key in actual:
|
|
185
|
+
return (actual[key], True)
|
|
186
|
+
return (None, False)
|
|
187
|
+
if hasattr(actual, key):
|
|
188
|
+
return (getattr(actual, key), True)
|
|
189
|
+
return (None, False)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _subset(actual, expect, path):
|
|
193
|
+
"""Assert that `actual` contains `expect`, recursively.
|
|
194
|
+
|
|
195
|
+
Cases assert only the fields they are about, so a full equality check
|
|
196
|
+
would force every case to restate the whole record.
|
|
197
|
+
"""
|
|
198
|
+
if isinstance(expect, dict):
|
|
199
|
+
for k, want in expect.items():
|
|
200
|
+
got, found = _member(actual, k)
|
|
201
|
+
assert found, "{}.{}: no such member".format(path, k)
|
|
202
|
+
_subset(got, want, "{}.{}".format(path, k))
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
if isinstance(expect, bool) or not isinstance(expect, (int, float)):
|
|
206
|
+
assert actual == expect, "{}: got {!r}, want {!r}".format(path, actual, expect)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
# Money is float arithmetic; compare with a tolerance far below any
|
|
210
|
+
# amount a case states.
|
|
211
|
+
assert isinstance(actual, (int, float)) and not isinstance(actual, bool), \
|
|
212
|
+
"{}: expected a number, got {!r}".format(path, actual)
|
|
213
|
+
assert abs(float(actual) - float(expect)) < 1e-9, \
|
|
214
|
+
"{}: got {!r}, want {!r}".format(path, actual, expect)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _record(client, name):
|
|
218
|
+
return getattr(client, "_" + name, None)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class TestFeatureCorpus:
|
|
222
|
+
|
|
223
|
+
def test_corpus_carries_a_feature_section(self):
|
|
224
|
+
# A corpus with no `feature` section is a SKIP, not a failure. Each
|
|
225
|
+
# project carries its OWN materialised copy of .sdk/test/test.json, so a
|
|
226
|
+
# project scaffolded before the section existed legitimately has no cases
|
|
227
|
+
# to run - and a hard assertion here turned that into a red suite in every
|
|
228
|
+
# SDK on the fleet, for a corpus the project had simply not re-pulled yet.
|
|
229
|
+
# The strict check belongs where the corpus is CONTROLLED: sdkgen's own
|
|
230
|
+
# end-to-end lane supplies one and requires the cases to actually run.
|
|
231
|
+
if _load_corpus().get("feature") is None:
|
|
232
|
+
pytest.skip("this project's test.json has no `feature` section "
|
|
233
|
+
"- recompile the corpus (create-sdkgen "
|
|
234
|
+
".sdk/test/feature/) to run these cases")
|
|
235
|
+
|
|
236
|
+
def test_sdk_has_an_operation_the_corpus_can_drive(self):
|
|
237
|
+
# At least one operation, or every case below would skip and this
|
|
238
|
+
# would report green having run nothing.
|
|
239
|
+
assert len(_usable_ops(2)) > 0, \
|
|
240
|
+
"no declared operation completed against a plain 200 - the " \
|
|
241
|
+
"corpus cannot exercise a feature without one"
|
|
242
|
+
|
|
243
|
+
@pytest.mark.parametrize("name", FEATURE_CORPUS_NAMES)
|
|
244
|
+
def test_feature(self, name):
|
|
245
|
+
section = (_load_corpus().get("feature") or {}).get(name)
|
|
246
|
+
if section is None:
|
|
247
|
+
pytest.skip("no corpus section for {}".format(name))
|
|
248
|
+
|
|
249
|
+
cases = ((section.get("basic") or {}).get("set")) or []
|
|
250
|
+
assert len(cases) > 0, (
|
|
251
|
+
"corpus section feature.{} ran ZERO cases - a renamed section or "
|
|
252
|
+
"an emptied fixture must fail loudly, not pass silently".format(name))
|
|
253
|
+
|
|
254
|
+
# Probed by ACTIVATING it: the feature defaults to inactive, so an
|
|
255
|
+
# idle client never builds it and its absence says nothing.
|
|
256
|
+
probe = _client({"feature": [{"name": name, "active": True}]})
|
|
257
|
+
if _record(probe, name) is None:
|
|
258
|
+
pytest.skip("this SDK was generated without the {} feature".format(name))
|
|
259
|
+
|
|
260
|
+
ops = _usable_ops(2)
|
|
261
|
+
by_key = {o["key"]: o for o in ops}
|
|
262
|
+
|
|
263
|
+
ran = 0
|
|
264
|
+
for raw in cases:
|
|
265
|
+
need = _tokens_used(raw)
|
|
266
|
+
if need > len(ops):
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
tokens = {}
|
|
270
|
+
for i in range(need):
|
|
271
|
+
tokens["#OP{}".format(i + 1)] = ops[i]["key"]
|
|
272
|
+
kase = _resolve(raw, tokens)
|
|
273
|
+
|
|
274
|
+
client = _client(kase)
|
|
275
|
+
label = kase.get("name")
|
|
276
|
+
|
|
277
|
+
for step in (kase.get("op") or []):
|
|
278
|
+
op = by_key.get(step["op"])
|
|
279
|
+
assert op is not None, "{}: no operation {}".format(label, step["op"])
|
|
280
|
+
ctrl = step.get("ctrl") or {}
|
|
281
|
+
wanterr = step.get("err")
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
_invoke(client, op, ctrl)
|
|
285
|
+
assert wanterr is None, \
|
|
286
|
+
"{}: {} was expected to fail, and did not".format(label, step["op"])
|
|
287
|
+
except AssertionError:
|
|
288
|
+
raise
|
|
289
|
+
except Exception as err:
|
|
290
|
+
assert wanterr is not None, \
|
|
291
|
+
"{}: {} failed unexpectedly: {}".format(label, step["op"], err)
|
|
292
|
+
if isinstance(wanterr, str):
|
|
293
|
+
# The CODE, not the message: makeError prefixes and
|
|
294
|
+
# humanises the text, so matching it would pass on any
|
|
295
|
+
# error that happened to mention the word.
|
|
296
|
+
code = getattr(err, "code", None)
|
|
297
|
+
assert code == wanterr, \
|
|
298
|
+
"{}: wrong error code: got {!r} ({}), want {!r}".format(
|
|
299
|
+
label, code, err, wanterr)
|
|
300
|
+
|
|
301
|
+
_subset(_record(client, name), kase.get("out"), "{}: _{}".format(label, name))
|
|
302
|
+
ran += 1
|
|
303
|
+
|
|
304
|
+
assert ran > 0, "every feature.{} case was skipped".format(name)
|
|
305
|
+
# Say how many ran. A partial run is legitimate (an SDK with one
|
|
306
|
+
# operation skips the cases needing two) but it should be visible
|
|
307
|
+
# rather than inferred from a green tick.
|
|
308
|
+
print("feature.{}: ran {} of {} case(s) against {} operation(s)".format(
|
|
309
|
+
name, ran, len(cases), len(ops)))
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# ProjectName SDK feature corpus test
|
|
2
|
+
#
|
|
3
|
+
# Feature behaviour, driven by the SHARED corpus.
|
|
4
|
+
#
|
|
5
|
+
# The same route primary_utility_test.rb takes for the utilities:
|
|
6
|
+
# language-neutral cases in .sdk/test/test.json, executed against THIS
|
|
7
|
+
# generated SDK. The feature is the ordinary class, built by the generated
|
|
8
|
+
# config, installed by the generated constructor, and driven by a real entity
|
|
9
|
+
# operation. Not a miniature of the pipeline, which can only be as right as
|
|
10
|
+
# the miniature.
|
|
11
|
+
#
|
|
12
|
+
# Everything in a case is data. The one piece ruby writes for itself is
|
|
13
|
+
# turning scripted responses into a fetcher, through the documented
|
|
14
|
+
# `utility.fetcher` override.
|
|
15
|
+
|
|
16
|
+
require "minitest/autorun"
|
|
17
|
+
require "json"
|
|
18
|
+
require_relative "../ProjectName_sdk"
|
|
19
|
+
|
|
20
|
+
class FeatureCorpusTest < Minitest::Test
|
|
21
|
+
|
|
22
|
+
# Features with a corpus section. A name here with no section is a skip,
|
|
23
|
+
# not a failure: an SDK generated without the feature has nothing to run.
|
|
24
|
+
FEATURE_CORPUS_NAMES = ["cost"].freeze
|
|
25
|
+
|
|
26
|
+
# The standard operation names, in the order the runner prefers them.
|
|
27
|
+
FEATURE_CORPUS_OPS = %w[load list create update remove].freeze
|
|
28
|
+
|
|
29
|
+
def corpus
|
|
30
|
+
@corpus ||= JSON.parse(
|
|
31
|
+
File.read(File.join(__dir__, "..", "..", ".sdk", "test", "test.json")))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A scripted transport built from a case's `res` list. Responses are
|
|
35
|
+
# consumed in order and the last one repeats, so a case that does not care
|
|
36
|
+
# how many attempts happen need only declare one.
|
|
37
|
+
#
|
|
38
|
+
# Returns the shape the real fetcher returns: a [response, err] PAIR, with
|
|
39
|
+
# the parsed body behind a `json` lambda and `body` as the raw string. A
|
|
40
|
+
# script that only set `body` would look like an empty result, which reads
|
|
41
|
+
# as a feature defect rather than a mis-shaped script.
|
|
42
|
+
def scripted_fetcher(res)
|
|
43
|
+
n = -1
|
|
44
|
+
lambda do |_ctx, _fullurl, _fetchdef|
|
|
45
|
+
n += 1
|
|
46
|
+
spec = {}
|
|
47
|
+
if res.is_a?(Array) && !res.empty?
|
|
48
|
+
i = n >= res.length ? res.length - 1 : n
|
|
49
|
+
spec = res[i] || {}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
return [nil, RuntimeError.new("scripted transport failure")] if spec["throw"] == true
|
|
53
|
+
|
|
54
|
+
status = spec["status"].nil? ? 200 : spec["status"].to_i
|
|
55
|
+
body = spec["body"].nil? ? {} : spec["body"]
|
|
56
|
+
|
|
57
|
+
[{
|
|
58
|
+
"status" => status,
|
|
59
|
+
"statusText" => status < 400 ? "OK" : "ERR",
|
|
60
|
+
"headers" => (spec["headers"] || {}).dup,
|
|
61
|
+
"json" => -> { body },
|
|
62
|
+
"body" => JSON.generate(body),
|
|
63
|
+
}, nil]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Build a client the way a caller would.
|
|
68
|
+
#
|
|
69
|
+
# ProjectNameSDK.new, not ProjectNameSDK.test: the `test` feature is
|
|
70
|
+
# transport: 'base' and REPLACES the transport, so a client in test mode
|
|
71
|
+
# would shadow the script.
|
|
72
|
+
def build_client(kase)
|
|
73
|
+
opts = { "utility" => { "fetcher" => scripted_fetcher(kase["res"]) } }
|
|
74
|
+
opts["feature"] = kase["feature"] unless kase["feature"].nil?
|
|
75
|
+
ProjectNameSDK.new(opts)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Every operation this SDK declares, in a stable order.
|
|
79
|
+
#
|
|
80
|
+
# The corpus cannot name an entity - it is shared by SDKs with none in
|
|
81
|
+
# common - so the runner finds them here. An entity accessor is a
|
|
82
|
+
# capitalised client method whose result answers get_name.
|
|
83
|
+
def candidates(client)
|
|
84
|
+
found = {}
|
|
85
|
+
client.public_methods(false).each do |m|
|
|
86
|
+
name = m.to_s
|
|
87
|
+
next unless name[0] =~ /[A-Z]/
|
|
88
|
+
ent = begin
|
|
89
|
+
client.public_send(m)
|
|
90
|
+
rescue StandardError
|
|
91
|
+
next
|
|
92
|
+
end
|
|
93
|
+
next unless ent.respond_to?(:get_name)
|
|
94
|
+
entname = begin
|
|
95
|
+
ent.get_name
|
|
96
|
+
rescue StandardError
|
|
97
|
+
next
|
|
98
|
+
end
|
|
99
|
+
next unless entname.is_a?(String) && !entname.empty?
|
|
100
|
+
found[entname] = [name, ent]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
out = []
|
|
104
|
+
found.keys.sort.each do |entname|
|
|
105
|
+
accessor, ent = found[entname]
|
|
106
|
+
FEATURE_CORPUS_OPS.each do |opname|
|
|
107
|
+
next unless ent.respond_to?(opname)
|
|
108
|
+
out << { "key" => "#{entname}.#{opname}", "accessor" => accessor, "op" => opname }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
out
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def invoke(client, op, ctrl)
|
|
115
|
+
client.public_send(op["accessor"]).public_send(op["op"], {}, ctrl)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Pick operations by DRIVING them: an op is usable when it completes
|
|
119
|
+
# against a plain 200 with no feature active. Declared operations are not
|
|
120
|
+
# all callable with no arguments, and a case failing for that reason would
|
|
121
|
+
# read as a feature defect.
|
|
122
|
+
def usable_ops(want)
|
|
123
|
+
picked = []
|
|
124
|
+
candidates(build_client({})).each do |cand|
|
|
125
|
+
begin
|
|
126
|
+
invoke(build_client({}), cand, {})
|
|
127
|
+
rescue StandardError
|
|
128
|
+
next
|
|
129
|
+
end
|
|
130
|
+
picked << cand
|
|
131
|
+
break if picked.length >= want
|
|
132
|
+
end
|
|
133
|
+
picked
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Replace #OPn throughout a case, keys included.
|
|
137
|
+
def resolve(node, tokens)
|
|
138
|
+
case node
|
|
139
|
+
when String
|
|
140
|
+
out = node.dup
|
|
141
|
+
tokens.each { |tok, val| out = out.gsub(tok, val) }
|
|
142
|
+
out
|
|
143
|
+
when Array
|
|
144
|
+
node.map { |n| resolve(n, tokens) }
|
|
145
|
+
when Hash
|
|
146
|
+
node.each_with_object({}) { |(k, v), h| h[resolve(k, tokens)] = resolve(v, tokens) }
|
|
147
|
+
else
|
|
148
|
+
node
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The highest #OPn a case mentions.
|
|
153
|
+
def tokens_used(kase)
|
|
154
|
+
JSON.generate(kase).scan(/#OP(\d+)/).flatten.map(&:to_i).max || 0
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def member(actual, key)
|
|
158
|
+
return [nil, false] if actual.nil?
|
|
159
|
+
return [actual[key], true] if actual.is_a?(Hash) && actual.key?(key)
|
|
160
|
+
return [actual.public_send(key), true] if actual.respond_to?(key)
|
|
161
|
+
[nil, false]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Assert that `actual` contains `expect`, recursively. Cases assert only
|
|
165
|
+
# the fields they are about, so a full equality check would force every
|
|
166
|
+
# case to restate the whole record.
|
|
167
|
+
def subset(actual, expect, path)
|
|
168
|
+
if expect.is_a?(Hash)
|
|
169
|
+
expect.each do |k, want|
|
|
170
|
+
got, found = member(actual, k)
|
|
171
|
+
assert found, "#{path}.#{k}: no such member"
|
|
172
|
+
subset(got, want, "#{path}.#{k}")
|
|
173
|
+
end
|
|
174
|
+
return
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if expect.is_a?(Numeric)
|
|
178
|
+
assert actual.is_a?(Numeric), "#{path}: expected a number, got #{actual.inspect}"
|
|
179
|
+
# Money is float arithmetic; compare with a tolerance far below any
|
|
180
|
+
# amount a case states.
|
|
181
|
+
assert (actual.to_f - expect.to_f).abs < 1e-9,
|
|
182
|
+
"#{path}: got #{actual.inspect}, want #{expect.inspect}"
|
|
183
|
+
return
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
assert_equal expect, actual, path
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def record(client, name)
|
|
190
|
+
client.instance_variable_get(:"@_#{name}")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def test_corpus_carries_a_feature_section
|
|
194
|
+
# A corpus with no `feature` section is a SKIP, not a failure. Each
|
|
195
|
+
# project carries its OWN materialised copy of .sdk/test/test.json, so a
|
|
196
|
+
# project scaffolded before the section existed legitimately has no cases
|
|
197
|
+
# to run - and a hard assertion here turned that into a red suite in every
|
|
198
|
+
# SDK on the fleet, for a corpus the project had simply not re-pulled yet.
|
|
199
|
+
# The strict check belongs where the corpus is CONTROLLED: sdkgen's own
|
|
200
|
+
# end-to-end lane supplies one and requires the cases to actually run.
|
|
201
|
+
if corpus["feature"].nil?
|
|
202
|
+
skip("this project's test.json has no `feature` section - recompile " \
|
|
203
|
+
"the corpus (create-sdkgen .sdk/test/feature/) to run these cases")
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# At least one operation, or every case would skip and this suite would
|
|
208
|
+
# report green having run nothing.
|
|
209
|
+
def test_sdk_has_an_operation_the_corpus_can_drive
|
|
210
|
+
refute_empty usable_ops(2),
|
|
211
|
+
"no declared operation completed against a plain 200 - the " \
|
|
212
|
+
"corpus cannot exercise a feature without one"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def test_feature_corpus
|
|
216
|
+
FEATURE_CORPUS_NAMES.each do |name|
|
|
217
|
+
section = (corpus["feature"] || {})[name]
|
|
218
|
+
next if section.nil?
|
|
219
|
+
|
|
220
|
+
cases = ((section["basic"] || {})["set"]) || []
|
|
221
|
+
refute_empty cases,
|
|
222
|
+
"corpus section feature.#{name} ran ZERO cases - a renamed " \
|
|
223
|
+
"section or an emptied fixture must fail loudly"
|
|
224
|
+
|
|
225
|
+
# Probed by ACTIVATING it: the feature defaults to inactive, so an idle
|
|
226
|
+
# client never builds it and its absence says nothing.
|
|
227
|
+
probe = build_client({ "feature" => [{ "name" => name, "active" => true }] })
|
|
228
|
+
next if record(probe, name).nil?
|
|
229
|
+
|
|
230
|
+
ops = usable_ops(2)
|
|
231
|
+
by_key = ops.each_with_object({}) { |o, h| h[o["key"]] = o }
|
|
232
|
+
|
|
233
|
+
ran = 0
|
|
234
|
+
cases.each do |raw|
|
|
235
|
+
need = tokens_used(raw)
|
|
236
|
+
next if need > ops.length
|
|
237
|
+
|
|
238
|
+
tokens = {}
|
|
239
|
+
need.times { |i| tokens["#OP#{i + 1}"] = ops[i]["key"] }
|
|
240
|
+
kase = resolve(raw, tokens)
|
|
241
|
+
|
|
242
|
+
client = build_client(kase)
|
|
243
|
+
label = kase["name"]
|
|
244
|
+
|
|
245
|
+
(kase["op"] || []).each do |step|
|
|
246
|
+
op = by_key[step["op"]]
|
|
247
|
+
refute_nil op, "#{label}: no operation #{step['op']}"
|
|
248
|
+
ctrl = step["ctrl"] || {}
|
|
249
|
+
wanterr = step["err"]
|
|
250
|
+
|
|
251
|
+
begin
|
|
252
|
+
invoke(client, op, ctrl)
|
|
253
|
+
assert_nil wanterr, "#{label}: #{step['op']} was expected to fail, and did not"
|
|
254
|
+
rescue Minitest::Assertion
|
|
255
|
+
raise
|
|
256
|
+
rescue StandardError => e
|
|
257
|
+
refute_nil wanterr, "#{label}: #{step['op']} failed unexpectedly: #{e}"
|
|
258
|
+
if wanterr.is_a?(String)
|
|
259
|
+
# The CODE, not the message: make_error prefixes and humanises
|
|
260
|
+
# the text, so matching it would pass on any error that
|
|
261
|
+
# happened to mention the word.
|
|
262
|
+
code = e.respond_to?(:code) ? e.code : nil
|
|
263
|
+
assert_equal wanterr, code,
|
|
264
|
+
"#{label}: wrong error code (#{e})"
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
subset(record(client, name), kase["out"], "#{label}: _#{name}")
|
|
270
|
+
ran += 1
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
assert ran > 0, "every feature.#{name} case was skipped"
|
|
274
|
+
# Say how many ran. A partial run is legitimate (an SDK with one
|
|
275
|
+
# operation skips the cases needing two) but it should be visible
|
|
276
|
+
# rather than inferred from a green tick.
|
|
277
|
+
puts "feature.#{name}: ran #{ran} of #{cases.length} case(s) " \
|
|
278
|
+
"against #{ops.length} operation(s)"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -4,9 +4,40 @@ module ProjectNameUtilities
|
|
|
4
4
|
MakeOptions = ->(ctx) {
|
|
5
5
|
options = ctx.options || {}
|
|
6
6
|
|
|
7
|
+
# Merge custom utility overrides.
|
|
8
|
+
#
|
|
9
|
+
# A key naming a real utility member REPLACES it; anything else is
|
|
10
|
+
# attached as a custom extra. This mirrors ts, where the utility is an
|
|
11
|
+
# open object and one setprop does both.
|
|
12
|
+
#
|
|
13
|
+
# Without the replace half this was a no-op: every entry went to
|
|
14
|
+
# `utility.custom`, which nothing reads, so a caller passing
|
|
15
|
+
# `"utility" => {"fetcher" => my_transport}` - the documented way to
|
|
16
|
+
# script the transport, and the seam the shared feature corpus runs on -
|
|
17
|
+
# was silently ignored while ts and js honoured it.
|
|
18
|
+
#
|
|
19
|
+
# Option keys are camelCase, as ts spells them; members here are
|
|
20
|
+
# snake_case. Converting rather than listing keeps the mapping to one
|
|
21
|
+
# rule, so a utility added later is overridable without touching this.
|
|
7
22
|
custom_utils = VoxgigStruct.getprop(options, "utility")
|
|
8
23
|
if custom_utils.is_a?(Hash) && ctx.utility
|
|
9
|
-
|
|
24
|
+
utility = ctx.utility
|
|
25
|
+
custom_utils.each do |k, v|
|
|
26
|
+
# Public utility names are camelCase and carry no underscore, so an
|
|
27
|
+
# underscore means the caller named something of their own - possibly
|
|
28
|
+
# the INTERNAL spelling of a real member. `make_error` must stay an
|
|
29
|
+
# extension in `custom`; replacing the pipeline function with it (ts,
|
|
30
|
+
# js and go all keep it) would break the error path on the next
|
|
31
|
+
# request, silently.
|
|
32
|
+
public_name = !k.to_s.include?("_")
|
|
33
|
+
member = k.to_s.gsub(/([A-Z])/) { "_#{$1.downcase}" }
|
|
34
|
+
setter = "#{member}="
|
|
35
|
+
if public_name && member != "custom" && utility.respond_to?(setter)
|
|
36
|
+
utility.public_send(setter, v)
|
|
37
|
+
else
|
|
38
|
+
utility.custom[k] = v
|
|
39
|
+
end
|
|
40
|
+
end
|
|
10
41
|
end
|
|
11
42
|
|
|
12
43
|
opts = VoxgigStruct.clone(options)
|