@voxgig/sdkgen 0.45.0 → 1.0.1

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 (65) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/package.json +3 -3
  3. package/project/.sdk/src/cmp/go/Entity_go.ts +2 -2
  4. package/project/.sdk/src/cmp/go/Main_go.ts +8 -4
  5. package/project/.sdk/src/cmp/go/Package_go.ts +2 -2
  6. package/project/.sdk/src/cmp/go/ReadmeExplanation_go.ts +2 -2
  7. package/project/.sdk/src/cmp/go/ReadmeHowto_go.ts +2 -2
  8. package/project/.sdk/src/cmp/go/ReadmeInstall_go.ts +2 -2
  9. package/project/.sdk/src/cmp/go/ReadmeModel_go.ts +2 -2
  10. package/project/.sdk/src/cmp/go/ReadmeQuick_go.ts +2 -2
  11. package/project/.sdk/src/cmp/go/ReadmeTopQuick_go.ts +2 -2
  12. package/project/.sdk/src/cmp/go/ReadmeTopTest_go.ts +2 -2
  13. package/project/.sdk/src/cmp/go/TestDirect_go.ts +204 -33
  14. package/project/.sdk/src/cmp/go/TestEntity_go.ts +37 -6
  15. package/project/.sdk/src/cmp/go/Test_go.ts +2 -2
  16. package/project/.sdk/src/cmp/go/fragment/Main.fragment.go +21 -4
  17. package/project/.sdk/src/cmp/lua/Package_lua.ts +9 -2
  18. package/project/.sdk/src/cmp/lua/TestDirect_lua.ts +154 -21
  19. package/project/.sdk/src/cmp/lua/TestEntity_lua.ts +25 -1
  20. package/project/.sdk/src/cmp/lua/fragment/Main.fragment.lua +20 -4
  21. package/project/.sdk/src/cmp/php/Package_php.ts +7 -1
  22. package/project/.sdk/src/cmp/php/TestDirect_php.ts +153 -20
  23. package/project/.sdk/src/cmp/php/TestEntity_php.ts +25 -1
  24. package/project/.sdk/src/cmp/php/fragment/Main.fragment.php +17 -3
  25. package/project/.sdk/src/cmp/py/Package_py.ts +8 -1
  26. package/project/.sdk/src/cmp/py/TestDirect_py.ts +146 -19
  27. package/project/.sdk/src/cmp/py/TestEntity_py.ts +25 -1
  28. package/project/.sdk/src/cmp/py/fragment/Main.fragment.py +19 -4
  29. package/project/.sdk/src/cmp/rb/Package_rb.ts +9 -2
  30. package/project/.sdk/src/cmp/rb/TestDirect_rb.ts +154 -21
  31. package/project/.sdk/src/cmp/rb/TestEntity_rb.ts +25 -1
  32. package/project/.sdk/src/cmp/rb/fragment/Main.fragment.rb +19 -3
  33. package/project/.sdk/src/cmp/ts/Package_ts.ts +1 -1
  34. package/project/.sdk/src/cmp/ts/TestDirect_ts.ts +145 -22
  35. package/project/.sdk/src/cmp/ts/TestEntity_ts.ts +59 -1
  36. package/project/.sdk/src/cmp/ts/fragment/Direct.test.fragment.ts +8 -1
  37. package/project/.sdk/src/cmp/ts/fragment/Entity.test.fragment.ts +8 -2
  38. package/project/.sdk/src/cmp/ts/fragment/Main.fragment.ts +21 -1
  39. package/project/.sdk/tm/go/feature/test_feature.go +51 -3
  40. package/project/.sdk/tm/go/test/runner_test.go +106 -6
  41. package/project/.sdk/tm/go/test/sdk-test-control.json +19 -0
  42. package/project/.sdk/tm/go/utility/fetcher.go +10 -0
  43. package/project/.sdk/tm/go/utility/make_url.go +12 -0
  44. package/project/.sdk/tm/lua/feature/test_feature.lua +41 -3
  45. package/project/.sdk/tm/lua/test/runner.lua +74 -0
  46. package/project/.sdk/tm/lua/test/sdk-test-control.json +19 -0
  47. package/project/.sdk/tm/lua/utility/fetcher.lua +13 -0
  48. package/project/.sdk/tm/lua/utility/make_url.lua +16 -0
  49. package/project/.sdk/tm/php/feature/TestFeature.php +185 -43
  50. package/project/.sdk/tm/php/test/Runner.php +62 -0
  51. package/project/.sdk/tm/php/test/sdk-test-control.json +19 -0
  52. package/project/.sdk/tm/php/utility/Fetcher.php +132 -9
  53. package/project/.sdk/tm/php/utility/MakeUrl.php +16 -0
  54. package/project/.sdk/tm/py/feature/test_feature.py +35 -3
  55. package/project/.sdk/tm/py/test/runner.py +60 -0
  56. package/project/.sdk/tm/py/test/sdk-test-control.json +19 -0
  57. package/project/.sdk/tm/py/utility/fetcher.py +13 -0
  58. package/project/.sdk/tm/py/utility/make_url.py +13 -0
  59. package/project/.sdk/tm/rb/feature/test_feature.rb +36 -3
  60. package/project/.sdk/tm/rb/test/runner.rb +46 -0
  61. package/project/.sdk/tm/rb/test/sdk-test-control.json +19 -0
  62. package/project/.sdk/tm/rb/utility/fetcher.rb +49 -28
  63. package/project/.sdk/tm/rb/utility/make_url.rb +16 -0
  64. package/project/.sdk/tm/ts/test/sdk-test-control.json +19 -0
  65. package/project/.sdk/tm/ts/test/utility.ts +120 -2
@@ -0,0 +1,19 @@
1
+ {
2
+ "version": 1,
3
+ "_doc": "Per-SDK test control. Lists tests/operations to skip in unit and live modes; tunes per-test pacing. Edit by hand. Loaded by go test runner.",
4
+ "test": {
5
+ "skip": {
6
+ "live": {
7
+ "direct": [],
8
+ "entityOp": []
9
+ },
10
+ "unit": {
11
+ "direct": [],
12
+ "entityOp": []
13
+ }
14
+ },
15
+ "live": {
16
+ "delayMs": 500
17
+ }
18
+ }
19
+ }
@@ -27,13 +27,23 @@ func defaultHTTPFetch(fullurl string, fetchdef map[string]any) (map[string]any,
27
27
  return nil, err
28
28
  }
29
29
 
30
+ hasUA := false
30
31
  if headers, ok := fetchdef["headers"].(map[string]any); ok {
31
32
  for k, v := range headers {
32
33
  if sv, ok := v.(string); ok {
34
+ if strings.EqualFold(k, "user-agent") {
35
+ hasUA = true
36
+ }
33
37
  req.Header.Set(k, sv)
34
38
  }
35
39
  }
36
40
  }
41
+ // Default User-Agent — Go's net/http defaults to "Go-http-client/1.1"
42
+ // which some CDNs block. Use a Mozilla-shaped UA unless the caller
43
+ // already set one.
44
+ if !hasUA {
45
+ req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; ProjectNameSDK/1.0)")
46
+ }
37
47
 
38
48
  resp, err := http.DefaultClient.Do(req)
39
49
  if err != nil {
@@ -35,6 +35,18 @@ func makeUrlUtil(ctx *core.Context) (string, error) {
35
35
  }
36
36
  }
37
37
 
38
+ // Append query string from spec.Query.
39
+ qsep := "?"
40
+ for _, item := range vs.Items(spec.Query) {
41
+ key, _ := item[0].(string)
42
+ val := item[1]
43
+ if val != nil {
44
+ url += qsep + vs.EscUrl(key) + "=" + vs.EscUrl(vs.Stringify(val))
45
+ qsep = "&"
46
+ resmatch[key] = val
47
+ }
48
+ }
49
+
38
50
  result.Resmatch = resmatch
39
51
 
40
52
  return url, nil
@@ -62,8 +62,27 @@ function TestFeature:init(ctx, options)
62
62
  entmap = {}
63
63
  end
64
64
 
65
+ -- For single-entity ops (load, remove) with an empty explicit match, fall
66
+ -- back to the id the entity client already knows from a prior create/load
67
+ -- (in fctx.match / fctx.data). Mirrors the TS mock where param() resolves
68
+ -- the id from that accumulated state.
69
+ local function resolve_match(explicit)
70
+ if type(explicit) == "table" and next(explicit) ~= nil then
71
+ return explicit
72
+ end
73
+ local function id_of(src)
74
+ if src == nil then return nil end
75
+ local v = vs.getprop(src, "id")
76
+ if v ~= nil and v ~= "__UNDEFINED__" then return v end
77
+ return nil
78
+ end
79
+ local v = id_of(fctx.match) or id_of(fctx.data)
80
+ if v ~= nil then return { id = v } end
81
+ return {}
82
+ end
83
+
65
84
  if op.name == "load" then
66
- local args = test_self:build_args(fctx, op, fctx.reqmatch)
85
+ local args = test_self:build_args(fctx, op, resolve_match(fctx.reqmatch))
67
86
  local found = vs.select(entmap, args)
68
87
  local ent = vs.getelem(found, 0)
69
88
  if ent == nil then
@@ -88,9 +107,28 @@ function TestFeature:init(ctx, options)
88
107
  return respond(200, out, nil)
89
108
 
90
109
  elseif op.name == "update" then
91
- local args = test_self:build_args(fctx, op, fctx.reqdata)
110
+ -- Match the existing entity by id only (or its alias). reqdata also
111
+ -- contains the new field values, which would otherwise cause select
112
+ -- to filter out the entity we want to update. Falls back to first
113
+ -- entity when no match found, mirroring the TS mock.
114
+ local update_match = {}
115
+ if type(fctx.reqdata) == "table" then
116
+ if fctx.reqdata["id"] ~= nil then update_match["id"] = fctx.reqdata["id"] end
117
+ if op.alias_map ~= nil then
118
+ local alias_id = vs.getprop(op.alias_map, "id")
119
+ if alias_id ~= nil and fctx.reqdata[alias_id] ~= nil then
120
+ update_match[alias_id] = fctx.reqdata[alias_id]
121
+ end
122
+ end
123
+ end
124
+ local args = test_self:build_args(fctx, op, update_match)
92
125
  local found = vs.select(entmap, args)
93
126
  local ent = vs.getelem(found, 0)
127
+ if ent == nil and type(entmap) == "table" then
128
+ for _, e in pairs(entmap) do
129
+ if type(e) == "table" then ent = e; break end
130
+ end
131
+ end
94
132
  if ent == nil then
95
133
  return respond(404, nil, { statusText = "Not found" })
96
134
  end
@@ -107,7 +145,7 @@ function TestFeature:init(ctx, options)
107
145
  return respond(200, out, nil)
108
146
 
109
147
  elseif op.name == "remove" then
110
- local args = test_self:build_args(fctx, op, fctx.reqmatch)
148
+ local args = test_self:build_args(fctx, op, resolve_match(fctx.reqmatch))
111
149
  local found = vs.select(entmap, args)
112
150
  local ent = vs.getelem(found, 0)
113
151
  if ent == nil then
@@ -83,4 +83,78 @@ function runner.entity_list_to_data(list)
83
83
  end
84
84
 
85
85
 
86
+ -- Load sdk-test-control.json from this test dir; cache. Returns an
87
+ -- empty-skip default if the file is missing or invalid.
88
+ runner._test_control = nil
89
+
90
+ function runner.load_test_control()
91
+ if runner._test_control ~= nil then
92
+ return runner._test_control
93
+ end
94
+ local this_dir = debug.getinfo(1, "S").source:match("^@(.+/)") or "./"
95
+ local ctrl_path = this_dir .. "sdk-test-control.json"
96
+ local f = io.open(ctrl_path, "r")
97
+ if f == nil then
98
+ runner._test_control = {
99
+ version = 1,
100
+ test = {
101
+ skip = {
102
+ live = { direct = {}, entityOp = {} },
103
+ unit = { direct = {}, entityOp = {} },
104
+ },
105
+ },
106
+ }
107
+ return runner._test_control
108
+ end
109
+ local content = f:read("*a")
110
+ f:close()
111
+ local parsed = json.decode(content)
112
+ if parsed == nil then
113
+ runner._test_control = {
114
+ version = 1,
115
+ test = {
116
+ skip = {
117
+ live = { direct = {}, entityOp = {} },
118
+ unit = { direct = {}, entityOp = {} },
119
+ },
120
+ },
121
+ }
122
+ else
123
+ runner._test_control = parsed
124
+ end
125
+ return runner._test_control
126
+ end
127
+
128
+
129
+ -- Check sdk-test-control.json for a skip entry. Returns (skip, reason).
130
+ function runner.is_control_skipped(kind, name, mode)
131
+ local ctrl = runner.load_test_control()
132
+ local skip = (((ctrl.test or {}).skip or {})[mode] or {})
133
+ local items = skip[kind] or {}
134
+ for _, item in ipairs(items) do
135
+ if kind == "direct" and item.test == name then
136
+ return true, item.reason
137
+ end
138
+ if kind == "entityOp" then
139
+ local key = (item.entity or "") .. "." .. (item.op or "")
140
+ if key == name then
141
+ return true, item.reason
142
+ end
143
+ end
144
+ end
145
+ return false, nil
146
+ end
147
+
148
+
149
+ -- Per-test live pacing delay (ms); default 500.
150
+ function runner.live_delay_ms()
151
+ local ctrl = runner.load_test_control()
152
+ local v = ((ctrl.test or {}).live or {}).delayMs
153
+ if type(v) == "number" and v >= 0 then
154
+ return v
155
+ end
156
+ return 500
157
+ end
158
+
159
+
86
160
  return runner
@@ -0,0 +1,19 @@
1
+ {
2
+ "version": 1,
3
+ "_doc": "Per-SDK test control. Lists tests/operations to skip in unit and live modes; tunes per-test pacing. Edit by hand. Loaded by lua test runner.",
4
+ "test": {
5
+ "skip": {
6
+ "live": {
7
+ "direct": [],
8
+ "entityOp": []
9
+ },
10
+ "unit": {
11
+ "direct": [],
12
+ "entityOp": []
13
+ }
14
+ },
15
+ "live": {
16
+ "delayMs": 500
17
+ }
18
+ }
19
+ }
@@ -25,6 +25,19 @@ local function default_http_fetch(fullurl, fetchdef)
25
25
  headers["content-length"] = tostring(#body_str)
26
26
  end
27
27
 
28
+ -- Default User-Agent — many CDNs reject requests with no UA. Set a
29
+ -- Mozilla-shaped UA unless the caller already set one.
30
+ local has_ua = false
31
+ for k, _ in pairs(headers) do
32
+ if string.lower(k) == "user-agent" then
33
+ has_ua = true
34
+ break
35
+ end
36
+ end
37
+ if not has_ua then
38
+ headers["User-Agent"] = "Mozilla/5.0 (compatible; ProjectNameSDK/1.0)"
39
+ end
40
+
28
41
  local response_body = {}
29
42
  local res, code, response_headers = http.request({
30
43
  url = fullurl,
@@ -38,6 +38,22 @@ local function make_url_util(ctx)
38
38
  end
39
39
  end
40
40
 
41
+ -- Append query string from spec.query.
42
+ local qsep = "?"
43
+ local query_items = vs.items(spec.query)
44
+ if query_items ~= nil then
45
+ for _, item in ipairs(query_items) do
46
+ local key = item[1]
47
+ local val = item[2]
48
+ if val ~= nil and type(key) == "string" then
49
+ local val_str = type(val) == "string" and val or tostring(val)
50
+ url = url .. qsep .. vs.escurl(key) .. "=" .. vs.escurl(val_str)
51
+ qsep = "&"
52
+ resmatch[key] = val
53
+ end
54
+ end
55
+ end
56
+
41
57
  result.resmatch = resmatch
42
58
 
43
59
  return url, nil
@@ -4,6 +4,7 @@ declare(strict_types=1);
4
4
  // ProjectName SDK test feature
5
5
 
6
6
  require_once __DIR__ . '/BaseFeature.php';
7
+ require_once __DIR__ . '/../utility/Param.php';
7
8
 
8
9
  class ProjectNameTestFeature extends ProjectNameBaseFeature
9
10
  {
@@ -45,7 +46,7 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
45
46
  $entity->data = $entity_data;
46
47
 
47
48
  $test_fetcher = function (ProjectNameContext $fctx, string $_fullurl, array $_fetchdef) use ($entity): array {
48
- $respond = function (int $status, mixed $data, ?array $extra): array {
49
+ $respond = function (int $status, mixed $data, ?array $extra = null): array {
49
50
  $out = [
50
51
  'status' => $status,
51
52
  'statusText' => 'OK',
@@ -64,53 +65,115 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
64
65
  $entname = $op->entity;
65
66
  $entmap = is_array($entity->data[$entname] ?? null) ? $entity->data[$entname] : [];
66
67
 
67
- // Extract id from context: reqmatch for load/remove, reqdata for update/create.
68
- $get_id = function () use ($fctx) {
69
- $sources = [$fctx->reqmatch, $fctx->reqdata, $fctx->data];
70
- foreach ($sources as $src) {
71
- if (is_array($src) && isset($src['id']) && $src['id'] !== '__UNDEFINED__') {
72
- return $src['id'];
68
+ // PHP-portable equivalent of TS buildArgs+select: a flat-key
69
+ // filter that matches by exact-equality on each provided key,
70
+ // with alias fallback. Empty match matches all entries — load
71
+ // with empty match returns the first fixture entry (or last
72
+ // create), list returns all entries.
73
+ $find_first = function (array $entmap, $match, $alias) {
74
+ if (!is_array($match) || empty($match)) {
75
+ foreach ($entmap as $e) {
76
+ if (is_array($e)) return $e;
77
+ }
78
+ return null;
79
+ }
80
+ foreach ($entmap as $e) {
81
+ if (!is_array($e)) continue;
82
+ $ok = true;
83
+ foreach ($match as $k => $v) {
84
+ if ($v === null || $v === '__UNDEFINED__') continue;
85
+ $ev = $e[$k] ?? null;
86
+ if ($ev !== $v) {
87
+ // Try alias key if any
88
+ $ka = is_array($alias) ? ($alias[$k] ?? null) : null;
89
+ $aliased = ($ka !== null) ? ($e[$ka] ?? null) : null;
90
+ if ($aliased !== $v) {
91
+ $ok = false;
92
+ break;
93
+ }
94
+ }
73
95
  }
96
+ if ($ok) return $e;
74
97
  }
75
98
  return null;
76
99
  };
77
100
 
78
- if ($op->name === 'load') {
79
- $id = $get_id();
80
- $ent = ($id !== null && isset($entmap[$id])) ? $entmap[$id] : null;
81
- if (!$ent) {
82
- // Fallback: search by id field value
83
- foreach ($entmap as $e) {
84
- if (is_array($e) && ($e['id'] ?? null) === $id) { $ent = $e; break; }
101
+ $find_all = function (array $entmap, $match, $alias) use ($find_first) {
102
+ if (!is_array($match) || empty($match)) {
103
+ return array_values(array_filter($entmap, 'is_array'));
104
+ }
105
+ $out = [];
106
+ foreach ($entmap as $e) {
107
+ if (!is_array($e)) continue;
108
+ $ok = true;
109
+ foreach ($match as $k => $v) {
110
+ if ($v === null || $v === '__UNDEFINED__') continue;
111
+ $ev = $e[$k] ?? null;
112
+ if ($ev !== $v) {
113
+ $ka = is_array($alias) ? ($alias[$k] ?? null) : null;
114
+ $aliased = ($ka !== null) ? ($e[$ka] ?? null) : null;
115
+ if ($aliased !== $v) { $ok = false; break; }
116
+ }
117
+ }
118
+ if ($ok) $out[] = $e;
119
+ }
120
+ return $out;
121
+ };
122
+
123
+ $alias = is_object($op) ? ($op->alias ?? null) : \Voxgig\Struct\Struct::getprop($op, 'alias');
124
+
125
+ // For single-entity ops (load, remove) with an empty explicit
126
+ // match, fall back to the id the entity client already knows from a
127
+ // prior create/load (carried in $fctx->match / $fctx->data). This
128
+ // mirrors the TS mock where param() resolves the id from that
129
+ // accumulated state — e.g. `create()` then `remove([])` removes the
130
+ // just-created entity, not an arbitrary fixture.
131
+ $resolve_match = function ($explicit) use ($fctx) {
132
+ if (is_array($explicit) && !empty($explicit)) return $explicit;
133
+ foreach ([$fctx->match, $fctx->data] as $src) {
134
+ $arr = is_array($src) ? $src : (is_object($src) ? (array) $src : []);
135
+ if (isset($arr['id']) && $arr['id'] !== null && $arr['id'] !== '__UNDEFINED__') {
136
+ return ['id' => $arr['id']];
85
137
  }
86
138
  }
87
- if (!$ent) {
139
+ return [];
140
+ };
141
+
142
+ if ($op->name === 'load') {
143
+ $ent = $find_first($entmap, $resolve_match($fctx->reqmatch), $alias);
144
+ if ($ent === null) {
88
145
  return $respond(404, null, ['statusText' => 'Not found']);
89
146
  }
147
+ if (is_array($ent)) unset($ent['$KEY']);
90
148
  $out = \Voxgig\Struct\Struct::clone($ent);
91
- if (is_array($out)) { unset($out['$KEY']); }
92
- return $respond(200, $out, null);
149
+ return $respond(200, $out);
93
150
 
94
151
  } elseif ($op->name === 'list') {
95
- $out = [];
96
- foreach ($entmap as $e) {
97
- if (is_array($e)) {
98
- $copy = $e;
99
- unset($copy['$KEY']);
100
- $out[] = $copy;
101
- }
152
+ $found = $find_all($entmap, $fctx->reqmatch, $alias);
153
+ $cleaned = [];
154
+ foreach ($found as $e) {
155
+ if (is_array($e)) unset($e['$KEY']);
156
+ $cleaned[] = $e;
102
157
  }
103
- return $respond(200, $out, null);
158
+ $out = \Voxgig\Struct\Struct::clone($cleaned);
159
+ return $respond(200, $out);
104
160
 
105
161
  } elseif ($op->name === 'update') {
106
- $id = $get_id();
107
- $ent = ($id !== null && isset($entmap[$id])) ? $entmap[$id] : null;
108
- if (!$ent) {
109
- foreach ($entmap as $e) {
110
- if (is_array($e) && ($e['id'] ?? null) === $id) { $ent = $e; break; }
162
+ // Match the existing entity by id only (or its alias). reqdata
163
+ // also contains the new field values, which would otherwise
164
+ // cause find_first to filter out the entity we want to update.
165
+ $update_match = [];
166
+ if (is_array($fctx->reqdata)) {
167
+ if (array_key_exists('id', $fctx->reqdata)) {
168
+ $update_match['id'] = $fctx->reqdata['id'];
169
+ }
170
+ $id_alias = is_array($alias) ? ($alias['id'] ?? null) : null;
171
+ if ($id_alias !== null && array_key_exists($id_alias, $fctx->reqdata)) {
172
+ $update_match[$id_alias] = $fctx->reqdata[$id_alias];
111
173
  }
112
174
  }
113
- if (!$ent) {
175
+ $ent = $find_first($entmap, $update_match, $alias);
176
+ if ($ent === null) {
114
177
  return $respond(404, null, ['statusText' => 'Not found']);
115
178
  }
116
179
  if (is_array($fctx->reqdata)) {
@@ -118,33 +181,42 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
118
181
  $ent[$k] = $v;
119
182
  }
120
183
  }
121
- $entmap[$id] = $ent;
122
- $entity->data[$entname] = $entmap;
184
+ $id = is_array($ent) ? ($ent['id'] ?? null) : null;
185
+ if ($id !== null) {
186
+ $entmap[$id] = $ent;
187
+ $entity->data[$entname] = $entmap;
188
+ }
189
+ if (is_array($ent)) unset($ent['$KEY']);
123
190
  $out = \Voxgig\Struct\Struct::clone($ent);
124
- if (is_array($out)) { unset($out['$KEY']); }
125
- return $respond(200, $out, null);
191
+ return $respond(200, $out);
126
192
 
127
193
  } elseif ($op->name === 'remove') {
128
- $id = $get_id();
129
- if ($id !== null && isset($entmap[$id])) {
194
+ $ent = $find_first($entmap, $resolve_match($fctx->reqmatch), $alias);
195
+ if ($ent === null) {
196
+ return $respond(404, null, ['statusText' => 'Not found']);
197
+ }
198
+ $id = is_array($ent) ? ($ent['id'] ?? null) : null;
199
+ if ($id !== null) {
130
200
  unset($entmap[$id]);
131
201
  $entity->data[$entname] = $entmap;
132
202
  }
133
- return $respond(200, null, null);
203
+ return $respond(200, null);
134
204
 
135
205
  } elseif ($op->name === 'create') {
136
- $id = $get_id();
137
- if ($id === null) {
138
- $id = sprintf('%04x%04x%04x%04x', random_int(0, 0xFFFF), random_int(0, 0xFFFF), random_int(0, 0xFFFF), random_int(0, 0xFFFF));
206
+ $id = ProjectNameParam::call($fctx, 'id');
207
+ if ($id === null || $id === '__UNDEFINED__') {
208
+ $id = sprintf('%04x%04x%04x%04x',
209
+ random_int(0, 0xFFFF), random_int(0, 0xFFFF),
210
+ random_int(0, 0xFFFF), random_int(0, 0xFFFF));
139
211
  }
140
212
 
141
213
  $ent = is_array($fctx->reqdata) ? $fctx->reqdata : [];
142
214
  $ent['id'] = $id;
143
215
  $entmap[$id] = $ent;
144
216
  $entity->data[$entname] = $entmap;
217
+ if (is_array($ent)) unset($ent['$KEY']);
145
218
  $out = \Voxgig\Struct\Struct::clone($ent);
146
- if (is_array($out)) { unset($out['$KEY']); }
147
- return $respond(200, $out, null);
219
+ return $respond(200, $out);
148
220
 
149
221
  } else {
150
222
  return $respond(404, null, ['statusText' => 'Unknown operation']);
@@ -153,4 +225,74 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
153
225
 
154
226
  $ctx->utility->fetcher = $test_fetcher;
155
227
  }
228
+
229
+ /**
230
+ * Build a structured `$AND` query from the request match/data dict,
231
+ * matching the TS test feature's buildArgs. Mirrors ts/src/feature/test/TestFeature.ts:158-204.
232
+ *
233
+ * For each key in $args that is 'id' OR a required-param key on the
234
+ * current operation point, emit a `$OR` clause matching the key (and
235
+ * its alias, if any) against the supplied value.
236
+ */
237
+ public function buildArgs(ProjectNameContext $ctx, $op, $args): array
238
+ {
239
+ // If args is empty/missing, return an empty $AND so select() matches
240
+ // every entry — the TS test feature relies on this for empty-match
241
+ // load against fixture entries.
242
+ $keys = is_array($args) ? \Voxgig\Struct\Struct::keysof($args) : [];
243
+ if (empty($keys)) {
244
+ return ['$AND' => []];
245
+ }
246
+
247
+ $opname = is_object($op) ? ($op->name ?? null) : (\Voxgig\Struct\Struct::getprop($op, 'name'));
248
+ $entityName = null;
249
+ if (isset($ctx->entity)) {
250
+ $entityName = is_object($ctx->entity)
251
+ ? ($ctx->entity->name ?? null)
252
+ : (is_array($ctx->entity) ? ($ctx->entity['name'] ?? null) : null);
253
+ }
254
+
255
+ // Resolve required-param names from the op's last point. Defensive:
256
+ // any missing piece falls back to "no required params".
257
+ $reqd_names = [];
258
+ if (is_string($opname) && is_string($entityName) && isset($ctx->config)) {
259
+ $points = \Voxgig\Struct\Struct::getpath(
260
+ ['entity', $entityName, 'op', $opname, 'points'],
261
+ $ctx->config
262
+ );
263
+ $point = \Voxgig\Struct\Struct::getelem($points, -1);
264
+ $params = is_array($point) ? ($point['args']['params'] ?? null) : null;
265
+ if (is_array($params)) {
266
+ foreach ($params as $p) {
267
+ if (is_array($p) && (($p['reqd'] ?? false) === true)) {
268
+ $n = $p['name'] ?? null;
269
+ if ($n !== null) {
270
+ $reqd_names[] = $n;
271
+ }
272
+ }
273
+ }
274
+ }
275
+ }
276
+
277
+ $alias = is_object($op) ? ($op->alias ?? null) : \Voxgig\Struct\Struct::getprop($op, 'alias');
278
+ $qand = [];
279
+
280
+ foreach ($keys as $k) {
281
+ $is_id = ($k === 'id');
282
+ $in_reqd = in_array($k, $reqd_names, true);
283
+ if ($is_id || $in_reqd) {
284
+ $v = ProjectNameParam::call($ctx, $k);
285
+ $ka = \Voxgig\Struct\Struct::getprop($alias, $k);
286
+
287
+ $qor = [[$k => $v]];
288
+ if ($ka !== null) {
289
+ $qor[] = [$ka => $v];
290
+ }
291
+
292
+ $qand[] = ['$OR' => $qor];
293
+ }
294
+ }
295
+
296
+ return ['$AND' => $qand];
297
+ }
156
298
  }
@@ -86,6 +86,68 @@ class ProjectNameTestRunner
86
86
  }
87
87
  return $out;
88
88
  }
89
+
90
+ private static $test_control = null;
91
+
92
+ /**
93
+ * Load sdk-test-control.json from this test dir; cache. Returns an
94
+ * empty-skip default if the file is missing or invalid.
95
+ */
96
+ public static function load_test_control(): array
97
+ {
98
+ if (self::$test_control !== null) {
99
+ return self::$test_control;
100
+ }
101
+ $ctrl_path = __DIR__ . '/sdk-test-control.json';
102
+ $default = [
103
+ 'version' => 1,
104
+ 'test' => ['skip' => [
105
+ 'live' => ['direct' => [], 'entityOp' => []],
106
+ 'unit' => ['direct' => [], 'entityOp' => []],
107
+ ]],
108
+ ];
109
+ if (!file_exists($ctrl_path)) {
110
+ self::$test_control = $default;
111
+ return self::$test_control;
112
+ }
113
+ $content = file_get_contents($ctrl_path);
114
+ $parsed = json_decode($content, true);
115
+ self::$test_control = is_array($parsed) ? $parsed : $default;
116
+ return self::$test_control;
117
+ }
118
+
119
+ /**
120
+ * Check sdk-test-control.json for a skip entry. Returns [skip, reason].
121
+ */
122
+ public static function is_control_skipped(string $kind, string $name, string $mode): array
123
+ {
124
+ $ctrl = self::load_test_control();
125
+ $skip = $ctrl['test']['skip'][$mode] ?? [];
126
+ $items = $skip[$kind] ?? [];
127
+ foreach ($items as $item) {
128
+ if ($kind === 'direct' && ($item['test'] ?? null) === $name) {
129
+ return [true, $item['reason'] ?? null];
130
+ }
131
+ if ($kind === 'entityOp') {
132
+ $key = ($item['entity'] ?? '') . '.' . ($item['op'] ?? '');
133
+ if ($key === $name) {
134
+ return [true, $item['reason'] ?? null];
135
+ }
136
+ }
137
+ }
138
+ return [false, null];
139
+ }
140
+
141
+ /** Per-test live pacing delay (ms); default 500. */
142
+ public static function live_delay_ms(): int
143
+ {
144
+ $ctrl = self::load_test_control();
145
+ $v = $ctrl['test']['live']['delayMs'] ?? null;
146
+ if (is_int($v) && $v >= 0) {
147
+ return $v;
148
+ }
149
+ return 500;
150
+ }
89
151
  }
90
152
 
91
153
  // Aliases for test convenience.
@@ -0,0 +1,19 @@
1
+ {
2
+ "version": 1,
3
+ "_doc": "Per-SDK test control. Lists tests/operations to skip in unit and live modes; tunes per-test pacing. Edit by hand. Loaded by php test runner.",
4
+ "test": {
5
+ "skip": {
6
+ "live": {
7
+ "direct": [],
8
+ "entityOp": []
9
+ },
10
+ "unit": {
11
+ "direct": [],
12
+ "entityOp": []
13
+ }
14
+ },
15
+ "live": {
16
+ "delayMs": 500
17
+ }
18
+ }
19
+ }