@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.
Files changed (33) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/dist/helpers/naming.d.ts +2 -1
  3. package/dist/helpers/naming.js +50 -12
  4. package/dist/helpers/naming.js.map +1 -1
  5. package/dist/sdkgen.d.ts +2 -2
  6. package/dist/sdkgen.js +4 -3
  7. package/dist/sdkgen.js.map +1 -1
  8. package/dist/tsconfig.tsbuildinfo +1 -1
  9. package/package.json +1 -1
  10. package/project/.sdk/tm/go/test/custom_utility_test.go +103 -0
  11. package/project/.sdk/tm/go/test/feature_corpus_test.go +550 -0
  12. package/project/.sdk/tm/go/utility/make_options.go +7 -1
  13. package/project/.sdk/tm/go/utility/register.go +194 -0
  14. package/project/.sdk/tm/java/test/CustomUtilityTest.java +55 -0
  15. package/project/.sdk/tm/java/test/FeatureCorpusTest.java +463 -0
  16. package/project/.sdk/tm/java/utility/MakeOptions.java +11 -2
  17. package/project/.sdk/tm/java/utility/Register.java +91 -0
  18. package/project/.sdk/tm/js/test/feature/Corpus.test.js +285 -0
  19. package/project/.sdk/tm/perl/t/feature_corpus.t +345 -0
  20. package/project/.sdk/tm/perl/utility/make_options.pm +33 -1
  21. package/project/.sdk/tm/php/core/Context.php +3 -0
  22. package/project/.sdk/tm/php/core/Control.php +13 -0
  23. package/project/.sdk/tm/php/core/Error.php +12 -0
  24. package/project/.sdk/tm/php/test/FeatureCorpusTest.php +376 -0
  25. package/project/.sdk/tm/php/utility/MakeOptions.php +30 -1
  26. package/project/.sdk/tm/py/pkg/utility/make_options.py +42 -1
  27. package/project/.sdk/tm/py/test/test_feature_corpus.py +309 -0
  28. package/project/.sdk/tm/rb/test/feature_corpus_test.rb +281 -0
  29. package/project/.sdk/tm/rb/utility/make_options.rb +32 -1
  30. package/project/.sdk/tm/ts/test/feature/Corpus.test.ts +287 -0
  31. package/project/sdkgen-package.json +1 -1
  32. package/src/helpers/naming.ts +54 -12
  33. 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
- custom_utils.each { |k, v| ctx.utility.custom[k] = v }
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)