@pi-in-go/pigpen-pi-typesafe-api 0.1.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/schema_test.go ADDED
@@ -0,0 +1,262 @@
1
+ package pitypesafe
2
+
3
+ import (
4
+ "encoding/json"
5
+ "math"
6
+ "reflect"
7
+ "strings"
8
+ "testing"
9
+
10
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
11
+ )
12
+
13
+ const secret = "private-user-content"
14
+
15
+ func nearMiss(t *testing.T) any {
16
+ return mustTree(t, `{"state":"synthetic","questions":{
17
+ "team":{"type":"choice","instructions":"Which team?","options":{"billing":"Charges and payments","other":"None of these"}},
18
+ "refund":{"type":"noul","instructions":"Is a refund requested?","criteria":"The sender asks for money back"},
19
+ "severity":{"type":"score","instructions":"How severe?","levels":["Cosmetic","Blocking"]},
20
+ "picks":{"type":"choice","instructions":"Pick one","choices":["a","b"]}}}`)
21
+ }
22
+
23
+ func criteriaOf(t *testing.T, r *Request, id string) string {
24
+ t.Helper()
25
+ q, _ := r.Questions.Get(id)
26
+ c, _ := q.(*Object).Get("criteria")
27
+ out, err := EncodeJSON(c)
28
+ if err != nil {
29
+ t.Fatal(err)
30
+ }
31
+ return string(out)
32
+ }
33
+
34
+ func TestSchema(t *testing.T) {
35
+ tw(t, "schema", "admission accepts the near-miss aliases the agent tool has always accepted", func(t *testing.T) {
36
+ p, err := PrepareEvaluationRequest(nearMiss(t), PrepareOptions{})
37
+ if err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ want := map[string]string{"team": `{"billing":"Charges and payments","other":"None of these"}`, "refund": `{"true":"The sender asks for money back"}`, "severity": `["Cosmetic","Blocking"]`, "picks": `{"a":null,"b":null}`}
41
+ for id, w := range want {
42
+ if got := criteriaOf(t, p, id); got != w {
43
+ t.Errorf("%s criteria = %s, want %s", id, got, w)
44
+ }
45
+ }
46
+ })
47
+ tw(t, "schema", "admission normalizes before it validates", func(t *testing.T) {
48
+ v := mustTree(t, `{"state":"s","questions":{"q":{"type":"choice","criteria":["a","b"]}}}`)
49
+ if _, err := ParseEvaluationRequest(v); !hasCode(err, CodeValidation) {
50
+ t.Fatalf("parse alone must reject: %v", err)
51
+ }
52
+ if _, err := PrepareEvaluationRequest(v, PrepareOptions{}); err != nil {
53
+ t.Fatalf("prepare must accept: %v", err)
54
+ }
55
+ })
56
+ tw(t, "schema", "normalization is idempotent", func(t *testing.T) {
57
+ once := NormalizeEvaluationRequest(nearMiss(t))
58
+ twice := NormalizeEvaluationRequest(once)
59
+ a, _ := EncodeJSON(once)
60
+ b, _ := EncodeJSON(twice)
61
+ if string(a) != string(b) {
62
+ t.Fatalf("%s != %s", a, b)
63
+ }
64
+ if _, err := PrepareEvaluationRequest(once, PrepareOptions{}); err != nil {
65
+ t.Fatal(err)
66
+ }
67
+ })
68
+ tw(t, "schema", "admission enforces the byte budget on the serialized request", func(t *testing.T) {
69
+ v := mustTree(t, `{"state":"`+strings.Repeat("🙂", 50)+`","questions":{"yes":{"type":"noul","instructions":"Is this synthetic?"}}}`)
70
+ body, _ := EncodeJSON(v)
71
+ n := len(body)
72
+ if n >= DefaultMaxInputBytes {
73
+ t.Fatal("fixture too large")
74
+ }
75
+ if _, err := PrepareEvaluationRequest(v, PrepareOptions{MaxInputBytes: n - 1}); !hasCode(err, CodeValidation) {
76
+ t.Fatalf("one byte under the limit must fail: %v", err)
77
+ }
78
+ if _, err := PrepareEvaluationRequest(v, PrepareOptions{MaxInputBytes: n}); err != nil {
79
+ t.Fatalf("exactly the limit must pass: %v", err)
80
+ }
81
+ })
82
+ tw(t, "schema", "the default byte budget is 64 KiB", func(t *testing.T) {
83
+ v := mustTree(t, `{"state":"`+strings.Repeat("x", DefaultMaxInputBytes)+`","questions":{"yes":{"type":"noul","instructions":"?"}}}`)
84
+ if DefaultMaxInputBytes != 65536 {
85
+ t.Fatal("default is not 64 KiB")
86
+ }
87
+ if _, err := PrepareEvaluationRequest(v, PrepareOptions{}); !hasCode(err, CodeValidation) {
88
+ t.Fatalf("oversized request must fail: %v", err)
89
+ }
90
+ if _, err := PrepareEvaluationRequest(v, PrepareOptions{MaxInputBytes: DefaultMaxInputBytes * 2}); err != nil {
91
+ t.Fatal(err)
92
+ }
93
+ })
94
+ tw(t, "schema", "admission still rejects non-JSON state", func(t *testing.T) {
95
+ cyc := map[string]any{}
96
+ cyc["self"] = cyc
97
+ for name, state := range map[string]any{"cycle": cyc, "nan": map[string]any{"n": math.NaN()}} {
98
+ _, err := PrepareEvaluationRequest(map[string]any{"state": state, "questions": map[string]any{"yes": map[string]any{"type": "noul", "instructions": "?"}}}, PrepareOptions{})
99
+ if !hasCode(err, CodeValidation) {
100
+ t.Errorf("%s: %v", name, err)
101
+ }
102
+ }
103
+ })
104
+ tw(t, "schema", "every field the agent authors carries a description, so a bare union is not its only guidance", func(t *testing.T) {
105
+ data, _ := json.Marshal(EvaluationSchema())
106
+ var schema any
107
+ _ = json.Unmarshal(data, &schema)
108
+ authored := map[string]int{}
109
+ var undescribed []string
110
+ var walk func(any)
111
+ walk = func(node any) {
112
+ switch n := node.(type) {
113
+ case []any:
114
+ for _, e := range n {
115
+ walk(e)
116
+ }
117
+ case map[string]any:
118
+ if props, ok := n["properties"].(map[string]any); ok {
119
+ for name, f := range props {
120
+ authored[name]++
121
+ if d, _ := f.(map[string]any)["description"].(string); d == "" {
122
+ undescribed = append(undescribed, name)
123
+ }
124
+ }
125
+ }
126
+ for _, v := range n {
127
+ walk(v)
128
+ }
129
+ }
130
+ }
131
+ walk(schema)
132
+ if authored["type"] != 3 {
133
+ t.Errorf("the schema must still offer noul, choice, and score: %d", authored["type"])
134
+ }
135
+ for _, name := range []string{"state", "questions", "model", "instructions", "criteria"} {
136
+ if authored[name] == 0 {
137
+ t.Errorf("%s is no longer authored", name)
138
+ }
139
+ }
140
+ for _, name := range undescribed {
141
+ // `true` and `false` inside noul criteria are the only fields whose parent already explains them.
142
+ if name != "true" && name != "false" {
143
+ t.Errorf("field %q has no description", name)
144
+ }
145
+ }
146
+ })
147
+ }
148
+
149
+ // The cases below are the client.test.ts cases that exercise admission and need no network.
150
+ func TestClientAdmission(t *testing.T) {
151
+ tw(t, "client", "structured state, descriptions, and null values are supported", func(t *testing.T) {
152
+ v := typesafe.SystemOneRequest{State: typesafe.Null, Questions: typesafe.Questions{
153
+ typesafe.Ask("yes", typesafe.Noul(map[string]any{"goal": "Check"}).Yes([]any{"a"}).No(nil)),
154
+ typesafe.Ask("pick", typesafe.Choice([]any{"Choose"}, typesafe.Opt("a", map[string]any{"detail": "a"}), typesafe.Opt("b", nil))),
155
+ typesafe.Ask("rating", typesafe.Score(nil, nil, map[string]any{"level": "high"})),
156
+ }}
157
+ if _, err := ParseEvaluationRequest(v); err != nil {
158
+ t.Fatal(err)
159
+ }
160
+ })
161
+ tw(t, "client", "validation errors name the offending path, never the submitted value", func(t *testing.T) {
162
+ cases := []struct {
163
+ request string
164
+ path string
165
+ }{
166
+ {`{"state":"` + secret + `","questions":{"q":{"type":"choice","instructions":"` + secret + `","criteria":["a",1]}}}`, `questions\.q`},
167
+ {`{"state":"` + secret + `","questions":{"q":{"type":"score","criteria":["` + secret + `"]}}}`, `questions\.q`},
168
+ {`{"state":"` + secret + `","questions":{}}`, `questions`},
169
+ {`{"state":"` + secret + `"}`, `questions|request`},
170
+ {`{"state":"` + secret + `","questions":{"q":{"type":"noul","instructions":"ok"}},"extra":"` + secret + `"}`, `extra`},
171
+ }
172
+ for _, c := range cases {
173
+ _, err := ParseEvaluationRequest(mustTree(t, c.request))
174
+ if !hasCode(err, CodeValidation) {
175
+ t.Fatalf("%s: %v", c.request, err)
176
+ }
177
+ msg := err.Error()
178
+ if !regexpMatch(c.path, msg) || !strings.Contains(msg, "Expected { state, questions") || strings.Contains(msg, secret) {
179
+ t.Errorf("message %q does not name %s, carry the usage, or leaks the value", msg, c.path)
180
+ }
181
+ }
182
+ })
183
+ tw(t, "client", "normalization accepts common model near-misses without loosening the schema", func(t *testing.T) {
184
+ v := mustTree(t, `{"state":"s","questions":{
185
+ "team":{"type":"choice","instructions":"Which team?","options":["frontend","backend"]},
186
+ "level":{"type":"score","instructions":"How bad?","levels":["fine","bad"]},
187
+ "flag":{"type":"noul","instructions":"Is it?","criteria":"Yes when stated"},
188
+ "keep":{"type":"choice","instructions":"Already valid","criteria":{"a":"A","b":null}}}}`)
189
+ r, err := ParseEvaluationRequest(NormalizeEvaluationRequest(v))
190
+ if err != nil {
191
+ t.Fatal(err)
192
+ }
193
+ q := func(id string) string { x, _ := r.Questions.Get(id); b, _ := EncodeJSON(x); return string(b) }
194
+ want := map[string]string{
195
+ "team": `{"type":"choice","instructions":"Which team?","criteria":{"frontend":null,"backend":null}}`,
196
+ "level": `{"type":"score","instructions":"How bad?","criteria":["fine","bad"]}`,
197
+ "flag": `{"type":"noul","instructions":"Is it?","criteria":{"true":"Yes when stated"}}`,
198
+ "keep": `{"type":"choice","instructions":"Already valid","criteria":{"a":"A","b":null}}`,
199
+ }
200
+ for id, w := range want {
201
+ if q(id) != w {
202
+ t.Errorf("%s = %s, want %s", id, q(id), w)
203
+ }
204
+ }
205
+ for _, in := range []any{nil, "text", []any{}, mustTree(t, `{"questions":[]}`), mustTree(t, `{"questions":{"q":"text"}}`)} {
206
+ out := NormalizeEvaluationRequest(in)
207
+ a, _ := EncodeJSON(in)
208
+ b, _ := EncodeJSON(out)
209
+ if string(a) != string(b) {
210
+ t.Errorf("normalize(%s) = %s", a, b)
211
+ }
212
+ }
213
+ bad := mustTree(t, `{"state":"s","questions":{"q":{"type":"choice","options":["a",2]}}}`)
214
+ if _, err := ParseEvaluationRequest(NormalizeEvaluationRequest(bad)); !hasCode(err, CodeValidation) {
215
+ t.Fatalf("a non-string label must stay invalid: %v", err)
216
+ }
217
+ })
218
+ tw(t, "client", "non-JSON state, getters, cycles, and excessive nesting are rejected", func(t *testing.T) {
219
+ // Getters, Date and BigInt have no Go counterpart: a Go value either marshals to JSON or it does not.
220
+ cycle := map[string]any{}
221
+ cycle["self"] = cycle
222
+ var nested any
223
+ for i := 0; i < 70; i++ {
224
+ nested = map[string]any{"nested": nested}
225
+ }
226
+ invalid := []any{cycle, map[string]any{"n": math.NaN()}, map[string]any{"n": math.Inf(1)}, map[string]any{"fn": func() {}}, map[string]any{"c": make(chan int)}, nested}
227
+ questions := map[string]any{"yes": map[string]any{"type": "noul", "instructions": "?"}}
228
+ for i, state := range invalid {
229
+ if _, err := ParseEvaluationRequest(map[string]any{"state": state, "questions": questions}); !hasCode(err, CodeValidation) {
230
+ t.Errorf("case %d: %v", i, err)
231
+ }
232
+ }
233
+ })
234
+ }
235
+
236
+ func TestRequestTypedKeepsOrder(t *testing.T) {
237
+ r, err := ParseEvaluationRequest(mustTree(t, `{"state":{"b":1,"a":2},"questions":{"z":{"type":"noul"},"a":{"type":"choice","criteria":{"y":null,"x":"d"}}},"model":"m"}`))
238
+ if err != nil {
239
+ t.Fatal(err)
240
+ }
241
+ typed, err := r.Typed()
242
+ if err != nil {
243
+ t.Fatal(err)
244
+ }
245
+ if len(typed.Questions) != 2 || typed.Questions[0].Name != "z" || typed.Questions[1].Name != "a" || typed.Model != "m" {
246
+ t.Fatalf("typed = %+v", typed)
247
+ }
248
+ body, _ := json.Marshal(typed)
249
+ if !strings.Contains(string(body), `"b":1,"a":2`) || !strings.Contains(string(body), `"y":null,"x":"d"`) {
250
+ t.Fatalf("order lost: %s", body)
251
+ }
252
+ }
253
+
254
+ func TestTreeQuestionOrderSurvivesAdmission(t *testing.T) {
255
+ r, err := PrepareEvaluationRequest(mustTree(t, `{"state":"s","questions":{"z":{"type":"noul"},"a":{"type":"noul"}}}`), PrepareOptions{})
256
+ if err != nil {
257
+ t.Fatal(err)
258
+ }
259
+ if got := r.Questions.Keys(); !reflect.DeepEqual(got, []string{"z", "a"}) {
260
+ t.Fatalf("keys = %v", got)
261
+ }
262
+ }
@@ -0,0 +1,5 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parseEvaluationRequest } from "./src/schema.ts";
3
+ const cases = JSON.parse(readFileSync("battery.json","utf8"));
4
+ const out = cases.map((c: unknown) => { try { parseEvaluationRequest(c); return "OK"; } catch (e) { return (e as Error).message.replace(/ Expected \{.*$/, ""); } });
5
+ console.log(JSON.stringify(out, null, 1));
@@ -0,0 +1,285 @@
1
+ [
2
+ {
3
+ "request": {
4
+ "state": "s"
5
+ },
6
+ "message": "Invalid evaluation request at request: must have required properties questions.",
7
+ "compared": true
8
+ },
9
+ {
10
+ "request": {
11
+ "questions": {}
12
+ },
13
+ "message": "Invalid evaluation request at request: must have required properties state; questions: must not have fewer than 1 properties.",
14
+ "compared": true
15
+ },
16
+ {
17
+ "request": {
18
+ "state": "s",
19
+ "questions": {}
20
+ },
21
+ "message": "Invalid evaluation request at questions: must not have fewer than 1 properties.",
22
+ "compared": true
23
+ },
24
+ {
25
+ "request": {
26
+ "state": 5,
27
+ "questions": {
28
+ "q": {
29
+ "type": "noul"
30
+ }
31
+ }
32
+ },
33
+ "message": "Invalid evaluation request at state: must be string; state: must be null; state: must be array.",
34
+ "compared": true
35
+ },
36
+ {
37
+ "request": {
38
+ "state": "s",
39
+ "questions": []
40
+ },
41
+ "message": "Invalid evaluation request at questions: must be object.",
42
+ "compared": true
43
+ },
44
+ {
45
+ "request": {
46
+ "state": "s",
47
+ "questions": {
48
+ "q": "x"
49
+ }
50
+ },
51
+ "message": "Invalid evaluation request at questions.q: must be object; questions.q: must be object; questions.q: must be object.",
52
+ "compared": true
53
+ },
54
+ {
55
+ "request": {
56
+ "state": "s",
57
+ "questions": {
58
+ "q": {
59
+ "type": "bad"
60
+ }
61
+ }
62
+ },
63
+ "message": "Invalid evaluation request at questions.q.type: must be equal to constant; questions.q: must have required properties criteria; questions.q.type: must be equal to constant.",
64
+ "compared": false
65
+ },
66
+ {
67
+ "request": {
68
+ "state": "s",
69
+ "questions": {
70
+ "q": {
71
+ "instructions": "x"
72
+ }
73
+ }
74
+ },
75
+ "message": "Invalid evaluation request at questions.q: must have required properties type; questions.q: must have required properties type, criteria; questions.q: must have required properties type, criteria.",
76
+ "compared": false
77
+ },
78
+ {
79
+ "request": {
80
+ "state": "s",
81
+ "questions": {
82
+ "q": {
83
+ "type": "noul",
84
+ "extra": 1
85
+ }
86
+ }
87
+ },
88
+ "message": "Invalid evaluation request at questions.q.extra: schema is false; questions.q: must not have additional properties; questions.q: must have required properties criteria.",
89
+ "compared": false
90
+ },
91
+ {
92
+ "request": {
93
+ "state": "s",
94
+ "questions": {
95
+ "q": {
96
+ "type": "noul",
97
+ "criteria": "x"
98
+ }
99
+ }
100
+ },
101
+ "message": "Invalid evaluation request at questions.q.criteria: must be null; questions.q.criteria: must be object; questions.q.criteria: must match a schema in anyOf.",
102
+ "compared": false
103
+ },
104
+ {
105
+ "request": {
106
+ "state": "s",
107
+ "questions": {
108
+ "q": {
109
+ "type": "noul",
110
+ "criteria": {
111
+ "maybe": "x"
112
+ }
113
+ }
114
+ }
115
+ },
116
+ "message": "Invalid evaluation request at questions.q.criteria: must be null; questions.q.criteria.maybe: schema is false; questions.q.criteria: must not have additional properties.",
117
+ "compared": false
118
+ },
119
+ {
120
+ "request": {
121
+ "state": "s",
122
+ "questions": {
123
+ "q": {
124
+ "type": "noul",
125
+ "instructions": 5
126
+ }
127
+ }
128
+ },
129
+ "message": "Invalid evaluation request at questions.q.instructions: must be string; questions.q.instructions: must be null; questions.q.instructions: must be array.",
130
+ "compared": false
131
+ },
132
+ {
133
+ "request": {
134
+ "state": "s",
135
+ "questions": {
136
+ "q": {
137
+ "type": "choice"
138
+ }
139
+ }
140
+ },
141
+ "message": "Invalid evaluation request at questions.q.type: must be equal to constant; questions.q: must have required properties criteria; questions.q: must have required properties criteria.",
142
+ "compared": false
143
+ },
144
+ {
145
+ "request": {
146
+ "state": "s",
147
+ "questions": {
148
+ "q": {
149
+ "type": "choice",
150
+ "criteria": {}
151
+ }
152
+ }
153
+ },
154
+ "message": "Invalid evaluation request at questions.q.type: must be equal to constant; questions.q.criteria: must not have fewer than 1 properties; questions.q.type: must be equal to constant.",
155
+ "compared": false
156
+ },
157
+ {
158
+ "request": {
159
+ "state": "s",
160
+ "questions": {
161
+ "q": {
162
+ "type": "choice",
163
+ "criteria": [
164
+ "a"
165
+ ]
166
+ }
167
+ }
168
+ },
169
+ "message": "Invalid evaluation request at questions.q.type: must be equal to constant; questions.q.criteria: must be null; questions.q.criteria: must be object.",
170
+ "compared": false
171
+ },
172
+ {
173
+ "request": {
174
+ "state": "s",
175
+ "questions": {
176
+ "q": {
177
+ "type": "choice",
178
+ "criteria": {
179
+ "a": 5
180
+ }
181
+ }
182
+ }
183
+ },
184
+ "message": "Invalid evaluation request at questions.q.type: must be equal to constant; questions.q.criteria: must be null; questions.q.criteria.a: schema is false.",
185
+ "compared": false
186
+ },
187
+ {
188
+ "request": {
189
+ "state": "s",
190
+ "questions": {
191
+ "q": {
192
+ "type": "score",
193
+ "criteria": [
194
+ "a"
195
+ ]
196
+ }
197
+ }
198
+ },
199
+ "message": "Invalid evaluation request at questions.q.type: must be equal to constant; questions.q.criteria: must be null; questions.q.criteria: must be object.",
200
+ "compared": false
201
+ },
202
+ {
203
+ "request": {
204
+ "state": "s",
205
+ "questions": {
206
+ "q": {
207
+ "type": "score",
208
+ "criteria": "x"
209
+ }
210
+ }
211
+ },
212
+ "message": "Invalid evaluation request at questions.q.type: must be equal to constant; questions.q.criteria: must be null; questions.q.criteria: must be object.",
213
+ "compared": false
214
+ },
215
+ {
216
+ "request": {
217
+ "state": "s",
218
+ "questions": {
219
+ "q": {
220
+ "type": "score",
221
+ "criteria": [
222
+ "a",
223
+ 5
224
+ ]
225
+ }
226
+ }
227
+ },
228
+ "message": "Invalid evaluation request at questions.q.type: must be equal to constant; questions.q.criteria: must be null; questions.q.criteria: must be object.",
229
+ "compared": false
230
+ },
231
+ {
232
+ "request": {
233
+ "state": "s",
234
+ "questions": {
235
+ "q": {
236
+ "type": "noul"
237
+ }
238
+ },
239
+ "model": ""
240
+ },
241
+ "message": "Invalid evaluation request at model: must not have fewer than 1 characters.",
242
+ "compared": true
243
+ },
244
+ {
245
+ "request": {
246
+ "state": "s",
247
+ "questions": {
248
+ "q": {
249
+ "type": "noul"
250
+ }
251
+ },
252
+ "model": 5
253
+ },
254
+ "message": "Invalid evaluation request at model: must be string.",
255
+ "compared": true
256
+ },
257
+ {
258
+ "request": {
259
+ "state": "s",
260
+ "questions": {
261
+ "q": {
262
+ "type": "noul"
263
+ }
264
+ },
265
+ "extra": 1
266
+ },
267
+ "message": "Invalid evaluation request at extra: schema is false; request: must not have additional properties.",
268
+ "compared": true
269
+ },
270
+ {
271
+ "request": [],
272
+ "message": "Invalid evaluation request at request: must be object.",
273
+ "compared": true
274
+ },
275
+ {
276
+ "request": "x",
277
+ "message": "Invalid evaluation request at request: must be object.",
278
+ "compared": true
279
+ },
280
+ {
281
+ "request": null,
282
+ "message": "Invalid evaluation request at request: must be object.",
283
+ "compared": true
284
+ }
285
+ ]
package/twin_test.go ADDED
@@ -0,0 +1,28 @@
1
+ package pitypesafe
2
+
3
+ // Twin helpers for a port whose original has tests. Every upstream test case gets one
4
+ // tw(...) call carrying the upstream title unchanged, or one tskip(...) with the reason it
5
+ // cannot be ported. `pigeq twins check` reads these calls and fails on a missing title, an
6
+ // unknown one (a misspelling) or one reason shared by many skips.
7
+ //
8
+ // pigeq twins list --tests <upstream>/test > port/upstream-tests.json
9
+ // pigeq twins check --ledger port/upstream-tests.json --go extensions/<name> [--files a,b]
10
+ //
11
+ // Copy this file into the port as twin_test.go and replace pitypesafe with the package name (in
12
+ // an external test package use `pitypesafe_test`). A parametrized upstream title (it contains
13
+ // `${`) is twinned once, with the template text unchanged.
14
+
15
+ import "testing"
16
+
17
+ // tw runs one exact twin: file is the upstream test file (its name without .test.mjs), title
18
+ // the upstream test title, unchanged.
19
+ func tw(t *testing.T, file, title string, fn func(t *testing.T)) {
20
+ t.Helper()
21
+ t.Run(title, fn)
22
+ }
23
+
24
+ // tskip records a named gap: the upstream case has no Go twin, with its own reason.
25
+ func tskip(t *testing.T, file, title, reason string) {
26
+ t.Helper()
27
+ t.Run(title, func(t *testing.T) { t.Skip(reason) })
28
+ }