@zyno-io/ts-reflection 26.803.2224
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/README.md +13 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +966 -0
- package/dist/reflection/annotations.d.ts +15 -0
- package/dist/reflection/annotations.d.ts.map +1 -0
- package/dist/reflection/compact-metadata.d.ts +18 -0
- package/dist/reflection/compact-metadata.d.ts.map +1 -0
- package/dist/reflection/conversion.d.ts +15 -0
- package/dist/reflection/conversion.d.ts.map +1 -0
- package/dist/reflection/deserializer.d.ts +15 -0
- package/dist/reflection/deserializer.d.ts.map +1 -0
- package/dist/reflection/errors.d.ts +8 -0
- package/dist/reflection/errors.d.ts.map +1 -0
- package/dist/reflection/index.d.ts +10 -0
- package/dist/reflection/index.d.ts.map +1 -0
- package/dist/reflection/metadata-store.d.ts +20 -0
- package/dist/reflection/metadata-store.d.ts.map +1 -0
- package/dist/reflection/model.d.ts +273 -0
- package/dist/reflection/model.d.ts.map +1 -0
- package/dist/reflection/primitive-conversion.d.ts +2 -0
- package/dist/reflection/primitive-conversion.d.ts.map +1 -0
- package/dist/reflection/reflection-class.d.ts +60 -0
- package/dist/reflection/reflection-class.d.ts.map +1 -0
- package/dist/reflection/type-utils.d.ts +34 -0
- package/dist/reflection/type-utils.d.ts.map +1 -0
- package/dist/type-compiler/download-prebuilt.cjs +114 -0
- package/dist/type-compiler/go/ast_expression.go +187 -0
- package/dist/type-compiler/go/ast_metadata.go +388 -0
- package/dist/type-compiler/go/collect.go +963 -0
- package/dist/type-compiler/go/compact_metadata.go +553 -0
- package/dist/type-compiler/go/emission_plan.go +340 -0
- package/dist/type-compiler/go/emit_ast.go +557 -0
- package/dist/type-compiler/go/emit_ast_test.go +558 -0
- package/dist/type-compiler/go/go.mod +10 -0
- package/dist/type-compiler/go/plugin.go +359 -0
- package/dist/type-compiler/go/plugin_test.go +1206 -0
- package/dist/type-compiler/go/precompute.go +86 -0
- package/dist/type-compiler/go/receive_type.go +912 -0
- package/dist/type-compiler/go/resolve.go +265 -0
- package/dist/type-compiler/go/source_scan.go +51 -0
- package/dist/type-compiler/go/text_parse.go +734 -0
- package/dist/type-compiler/go/type_expr.go +1291 -0
- package/dist/type-compiler/go/typia_expr.go +2316 -0
- package/dist/type-compiler/index.cjs +43 -0
- package/dist/type-compiler/pnp.cjs +474 -0
- package/dist/type-compiler/prebuilt.cjs +324 -0
- package/dist/type-metadata-runtime.cjs +1 -0
- package/dist/type-metadata-runtime.d.ts +2 -0
- package/dist/type-metadata-runtime.d.ts.map +1 -0
- package/dist/type-metadata-runtime.js +107 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/primitives.d.ts +33 -0
- package/dist/types/primitives.d.ts.map +1 -0
- package/dist/types/runtime.d.ts +2 -0
- package/dist/types/runtime.d.ts.map +1 -0
- package/dist/types/type-annotations.d.ts +28 -0
- package/dist/types/type-annotations.d.ts.map +1 -0
- package/package.json +47 -0
|
@@ -0,0 +1,1206 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"reflect"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
|
|
10
|
+
"github.com/samchon/ttsc/packages/ttsc/driver"
|
|
11
|
+
schemametadata "github.com/samchon/typia/packages/typia/native/core/schemas/metadata"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func testTypeInfo() (*fileInfo, *registry) {
|
|
15
|
+
info := &fileInfo{
|
|
16
|
+
moduleKey: "test/module",
|
|
17
|
+
aliases: map[string]aliasInfo{},
|
|
18
|
+
interfaces: map[string][]interfaceInfo{},
|
|
19
|
+
enums: map[string]enumInfo{},
|
|
20
|
+
classes: []*classInfo{},
|
|
21
|
+
functions: map[string][]functionInfo{},
|
|
22
|
+
imports: map[string]importRef{},
|
|
23
|
+
reexports: map[string]importRef{},
|
|
24
|
+
}
|
|
25
|
+
reg := ®istry{
|
|
26
|
+
files: map[string]*fileInfo{info.moduleKey: info},
|
|
27
|
+
byPath: map[string]*fileInfo{},
|
|
28
|
+
classes: map[string]*classInfo{},
|
|
29
|
+
external: map[string]map[string][]functionInfo{},
|
|
30
|
+
externalPackageRoots: map[string]string{},
|
|
31
|
+
}
|
|
32
|
+
return info, reg
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func TestExportedTypeAlgebraAliasesUseCheckerMetadata(t *testing.T) {
|
|
36
|
+
root := t.TempDir()
|
|
37
|
+
if err := os.WriteFile(filepath.Join(root, "tsconfig.json"), []byte(`{
|
|
38
|
+
"compilerOptions": {"strict": true, "target": "ESNext"},
|
|
39
|
+
"include": ["*.ts"],
|
|
40
|
+
"reflection": true
|
|
41
|
+
}`), 0o600); err != nil {
|
|
42
|
+
t.Fatal(err)
|
|
43
|
+
}
|
|
44
|
+
if err := os.WriteFile(filepath.Join(root, "source.ts"), []byte(`
|
|
45
|
+
export type SourceUnion = 'keep-first' | 'remove' | 'keep-second';
|
|
46
|
+
`), 0o600); err != nil {
|
|
47
|
+
t.Fatal(err)
|
|
48
|
+
}
|
|
49
|
+
if err := os.WriteFile(filepath.Join(root, "aliases.ts"), []byte(`
|
|
50
|
+
import type { SourceUnion } from './source';
|
|
51
|
+
export type ConditionalAlias = Exclude<SourceUnion, 'remove'>;
|
|
52
|
+
export type MappedAlias = Readonly<{ id: string; enabled: boolean }>;
|
|
53
|
+
`), 0o600); err != nil {
|
|
54
|
+
t.Fatal(err)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
program, diagnostics, err := driver.LoadProgram(root, "tsconfig.json", driver.LoadProgramOptions{SingleThreaded: true})
|
|
58
|
+
if err != nil {
|
|
59
|
+
t.Fatal(err)
|
|
60
|
+
}
|
|
61
|
+
if len(diagnostics) != 0 {
|
|
62
|
+
t.Fatalf("unexpected diagnostics: %v", diagnostics)
|
|
63
|
+
}
|
|
64
|
+
defer func() { _ = program.Close() }()
|
|
65
|
+
|
|
66
|
+
reg := collectRegistry(program, root, true, true)
|
|
67
|
+
var info *fileInfo
|
|
68
|
+
for _, candidate := range reg.files {
|
|
69
|
+
if candidate != nil && candidate.file != nil && filepath.Base(candidate.file.FileName()) == "aliases.ts" {
|
|
70
|
+
info = candidate
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if info == nil {
|
|
75
|
+
t.Fatal("aliases.ts was not collected")
|
|
76
|
+
}
|
|
77
|
+
conditionalAlias := info.aliases["ConditionalAlias"]
|
|
78
|
+
if !shouldResolveTypeAlgebraWithChecker(reg, conditionalAlias.body, conditionalAlias.typeNode) {
|
|
79
|
+
t.Fatal("checker did not recognize Exclude as type algebra")
|
|
80
|
+
}
|
|
81
|
+
if !shouldPreferTypiaAliasMetadata(info, reg, conditionalAlias.body, conditionalAlias.typeNode, conditionalAlias.pos) {
|
|
82
|
+
t.Fatal("checker-recognized Exclude alias did not prefer checker metadata")
|
|
83
|
+
}
|
|
84
|
+
conditional := info.aliases["ConditionalAlias"].metadataText
|
|
85
|
+
assertContainsAll(t, conditional, `literal: "keep-first"`, `literal: "keep-second"`)
|
|
86
|
+
assertNotContains(t, conditional, `literal: "remove"`)
|
|
87
|
+
assertNotContains(t, conditional, "classType")
|
|
88
|
+
nonPreferredConditional := typeExprForNode(info, reg, conditionalAlias.body, conditionalAlias.typeNode, conditionalAlias.pos)
|
|
89
|
+
assertContainsAll(t, nonPreferredConditional, `literal: "keep-first"`, `literal: "keep-second"`)
|
|
90
|
+
assertNotContains(t, nonPreferredConditional, `literal: "remove"`)
|
|
91
|
+
assertNotContains(t, nonPreferredConditional, "classType")
|
|
92
|
+
mapped := info.aliases["MappedAlias"].metadataText
|
|
93
|
+
assertContainsAll(t, mapped, `name: "id"`, `name: "enabled"`)
|
|
94
|
+
assertNotContains(t, mapped, "classType")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func TestTypiaObjectTypeNameKeepsGenericDisplayName(t *testing.T) {
|
|
98
|
+
info, reg := testTypeInfo()
|
|
99
|
+
info.interfaces["GenericEnvelope"] = []interfaceInfo{{body: "{ value: T }", pos: 1}}
|
|
100
|
+
|
|
101
|
+
name, ok := typiaObjectTypeName(info, reg, &schemametadata.MetadataObjectType{
|
|
102
|
+
Name: "GenericEnvelope",
|
|
103
|
+
DisplayName: "GenericEnvelope<string, Record<string, unknown>>",
|
|
104
|
+
}, 10)
|
|
105
|
+
|
|
106
|
+
if !ok {
|
|
107
|
+
t.Fatal("declared generic object display name should be stable")
|
|
108
|
+
}
|
|
109
|
+
if name != "GenericEnvelope<string, Record<string, unknown>>" {
|
|
110
|
+
t.Fatalf("name = %q", name)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
nestedName, nestedOk := typiaObjectTypeName(info, reg, &schemametadata.MetadataObjectType{
|
|
114
|
+
Name: "NestedEnvelope",
|
|
115
|
+
DisplayName: "NestedEnvelope<string>",
|
|
116
|
+
}, 10)
|
|
117
|
+
|
|
118
|
+
if !nestedOk {
|
|
119
|
+
t.Fatal("generic object display name should stay stable even when only the checker exposed it")
|
|
120
|
+
}
|
|
121
|
+
if nestedName != "NestedEnvelope<string>" {
|
|
122
|
+
t.Fatalf("nestedName = %q", nestedName)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func TestTypeExprInstantiatesGenericInterfaceProperties(t *testing.T) {
|
|
127
|
+
info, reg := testTypeInfo()
|
|
128
|
+
info.interfaces["GenericContainer"] = []interfaceInfo{{
|
|
129
|
+
params: []string{"T"},
|
|
130
|
+
properties: []utilityProperty{
|
|
131
|
+
{name: "items", typeText: "T[]", optional: true},
|
|
132
|
+
{name: "alternatives", typeText: "T[]", optional: true},
|
|
133
|
+
},
|
|
134
|
+
pos: 1,
|
|
135
|
+
}}
|
|
136
|
+
info.aliases["GenericVariant"] = aliasInfo{body: "{ kind: 'alpha' } | { kind: 'beta'; mode: 'first' | 'second' }"}
|
|
137
|
+
|
|
138
|
+
got := typeExpr(info, reg, "GenericContainer<GenericVariant>")
|
|
139
|
+
|
|
140
|
+
assertContainsAll(t, got,
|
|
141
|
+
`kind: 18, typeName: "GenericContainer"`,
|
|
142
|
+
`name: "items", type: {kind: 12, types: [{kind: 14, type: {kind: 12`,
|
|
143
|
+
`literal: "alpha"`,
|
|
144
|
+
`literal: "beta"`,
|
|
145
|
+
`literal: "first"`,
|
|
146
|
+
`literal: "second"`,
|
|
147
|
+
)
|
|
148
|
+
assertNotContains(t, got, `kind: 16, typeName: "GenericContainer"`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func TestTypeExprBoundsRecursiveGenericInterfaceExpansion(t *testing.T) {
|
|
152
|
+
info, reg := testTypeInfo()
|
|
153
|
+
info.interfaces["RecursiveContainer"] = []interfaceInfo{{
|
|
154
|
+
params: []string{"T"},
|
|
155
|
+
properties: []utilityProperty{
|
|
156
|
+
{name: "value", typeText: "T"},
|
|
157
|
+
{name: "next", typeText: "RecursiveContainer<T[]>", optional: true},
|
|
158
|
+
},
|
|
159
|
+
pos: 1,
|
|
160
|
+
}}
|
|
161
|
+
|
|
162
|
+
got := typeExpr(info, reg, "RecursiveContainer<string>")
|
|
163
|
+
|
|
164
|
+
assertContainsAll(t, got,
|
|
165
|
+
`kind: 18, typeName: "RecursiveContainer"`,
|
|
166
|
+
`name: "value", type: {kind: 6}`,
|
|
167
|
+
`name: "next", type: {kind: 12, types: [{kind: 2, typeName: "RecursiveContainer"}`,
|
|
168
|
+
)
|
|
169
|
+
if len(got) > 2000 {
|
|
170
|
+
t.Fatalf("recursive generic metadata should stay bounded, got %d bytes", len(got))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func TestSplitTopIgnoresNestedSyntax(t *testing.T) {
|
|
175
|
+
input := "Pick<User, 'id' | 'email'> | { value: string | number } | `x${a | b}` | Array<boolean>"
|
|
176
|
+
got := splitTop(input, "|")
|
|
177
|
+
want := []string{
|
|
178
|
+
"Pick<User, 'id' | 'email'>",
|
|
179
|
+
"{ value: string | number }",
|
|
180
|
+
"`x${a | b}`",
|
|
181
|
+
"Array<boolean>",
|
|
182
|
+
}
|
|
183
|
+
if !reflect.DeepEqual(got, want) {
|
|
184
|
+
t.Fatalf("splitTop() = %#v, want %#v", got, want)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
func TestSplitTopPreservesCommentMarkersInsideLiterals(t *testing.T) {
|
|
189
|
+
input := `'https://example.com/a/*b*/' | /* actual comment */ ` + "`route//segment/${string}`" + ` | 'done'`
|
|
190
|
+
got := splitTop(input, "|")
|
|
191
|
+
want := []string{
|
|
192
|
+
`'https://example.com/a/*b*/'`,
|
|
193
|
+
"`route//segment/${string}`",
|
|
194
|
+
`'done'`,
|
|
195
|
+
}
|
|
196
|
+
if !reflect.DeepEqual(got, want) {
|
|
197
|
+
t.Fatalf("splitTop() = %#v, want %#v", got, want)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
func TestTypeExprIgnoresCommentsInsideUnions(t *testing.T) {
|
|
202
|
+
file := parseTestSourceFile(t, "/project/commented-union.ts", `
|
|
203
|
+
type Status =
|
|
204
|
+
| 'draft'
|
|
205
|
+
/* client's legacy { branch */
|
|
206
|
+
| 'published'
|
|
207
|
+
// closing ( branch
|
|
208
|
+
| 'archived';
|
|
209
|
+
`)
|
|
210
|
+
alias := aliasFromNode(file, file.Statements.Nodes[0])
|
|
211
|
+
info, reg := testTypeInfo()
|
|
212
|
+
|
|
213
|
+
got := typeExpr(info, reg, alias.body)
|
|
214
|
+
assertContainsAll(t, got,
|
|
215
|
+
`literal: "draft"`,
|
|
216
|
+
`literal: "published"`,
|
|
217
|
+
`literal: "archived"`,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
func TestTypeExprForNodeUsesAstUnionMemberOrder(t *testing.T) {
|
|
222
|
+
file := parseTestSourceFile(t, "/project/ordered-union.ts", `
|
|
223
|
+
type Status =
|
|
224
|
+
| 'draft'
|
|
225
|
+
/* punctuation that would poison text splitting: client's { ( */
|
|
226
|
+
| 'published'
|
|
227
|
+
| 'archived';
|
|
228
|
+
`)
|
|
229
|
+
alias := aliasFromNode(file, file.Statements.Nodes[0])
|
|
230
|
+
info, reg := testTypeInfo()
|
|
231
|
+
info.file = file
|
|
232
|
+
|
|
233
|
+
got := typeExprForNode(info, reg, "'not-from-the-node'", alias.typeNode, alias.pos)
|
|
234
|
+
assertContainsAll(t, got,
|
|
235
|
+
`literal: "draft"`,
|
|
236
|
+
`literal: "published"`,
|
|
237
|
+
`literal: "archived"`,
|
|
238
|
+
)
|
|
239
|
+
assertNotContains(t, got, `literal: "not-from-the-node"`)
|
|
240
|
+
draft := strings.Index(got, `literal: "draft"`)
|
|
241
|
+
published := strings.Index(got, `literal: "published"`)
|
|
242
|
+
archived := strings.Index(got, `literal: "archived"`)
|
|
243
|
+
if draft > published || published > archived {
|
|
244
|
+
t.Fatalf("AST union order was not preserved: %s", got)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
func TestTypeExprForNodeUsesAstIntersectionMemberOrder(t *testing.T) {
|
|
249
|
+
file := parseTestSourceFile(t, "/project/ordered-intersection.ts", `
|
|
250
|
+
type Name =
|
|
251
|
+
string
|
|
252
|
+
& MinLength<2>
|
|
253
|
+
/* punctuation that would poison text splitting: { */
|
|
254
|
+
& MaxLength<20>;
|
|
255
|
+
`)
|
|
256
|
+
alias := aliasFromNode(file, file.Statements.Nodes[0])
|
|
257
|
+
info, reg := testTypeInfo()
|
|
258
|
+
info.file = file
|
|
259
|
+
|
|
260
|
+
got := typeExprForNode(info, reg, "string", alias.typeNode, alias.pos)
|
|
261
|
+
assertContainsAll(t, got,
|
|
262
|
+
`typeName: "MinLength"`,
|
|
263
|
+
`typeName: "MaxLength"`,
|
|
264
|
+
)
|
|
265
|
+
minimum := strings.Index(got, `typeName: "MinLength"`)
|
|
266
|
+
maximum := strings.Index(got, `typeName: "MaxLength"`)
|
|
267
|
+
if minimum > maximum {
|
|
268
|
+
t.Fatalf("AST intersection order was not preserved: %s", got)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
func TestIndexedAccessAliasPrefersCheckerMetadata(t *testing.T) {
|
|
273
|
+
info, reg := testTypeInfo()
|
|
274
|
+
|
|
275
|
+
if !shouldPreferTypiaAliasMetadata(info, reg, "(typeof PublishableKeyFeatures)[number]", nil, 1) {
|
|
276
|
+
t.Fatal("indexed-access aliases should be resolved by checker-backed metadata")
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
func TestRenderUtilityPropertyUsesAstUnionMembers(t *testing.T) {
|
|
281
|
+
file := parseTestSourceFile(t, "/project/utility-union.ts", `
|
|
282
|
+
type Status =
|
|
283
|
+
| 'draft'
|
|
284
|
+
/* punctuation that would poison text splitting: { */
|
|
285
|
+
| 'published';
|
|
286
|
+
`)
|
|
287
|
+
alias := aliasFromNode(file, file.Statements.Nodes[0])
|
|
288
|
+
info, reg := testTypeInfo()
|
|
289
|
+
info.file = file
|
|
290
|
+
|
|
291
|
+
got := renderUtilityProperty(info, reg, utilityProperty{
|
|
292
|
+
name: "status",
|
|
293
|
+
typeText: "'not-from-the-node'",
|
|
294
|
+
typeNode: alias.typeNode,
|
|
295
|
+
}, &typeContext{seen: map[string]bool{}})
|
|
296
|
+
assertContainsAll(t, got,
|
|
297
|
+
`literal: "draft"`,
|
|
298
|
+
`literal: "published"`,
|
|
299
|
+
)
|
|
300
|
+
assertNotContains(t, got, `literal: "not-from-the-node"`)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
func TestUtilityClassPropertyRetainsAstUnionMembers(t *testing.T) {
|
|
304
|
+
file := parseTestSourceFile(t, "/project/class-utility-union.ts", `
|
|
305
|
+
type Status =
|
|
306
|
+
| 'draft'
|
|
307
|
+
/* punctuation that would poison text splitting: { */
|
|
308
|
+
| 'published';
|
|
309
|
+
`)
|
|
310
|
+
alias := aliasFromNode(file, file.Statements.Nodes[0])
|
|
311
|
+
info, reg := testTypeInfo()
|
|
312
|
+
info.file = file
|
|
313
|
+
info.classes = []*classInfo{{
|
|
314
|
+
name: "User",
|
|
315
|
+
properties: []propertyInfo{{
|
|
316
|
+
name: "status",
|
|
317
|
+
typeText: "'not-from-the-node'",
|
|
318
|
+
typeNode: alias.typeNode,
|
|
319
|
+
}},
|
|
320
|
+
}}
|
|
321
|
+
|
|
322
|
+
got := typeExpr(info, reg, "Pick<User, 'status'>")
|
|
323
|
+
assertContainsAll(t, got,
|
|
324
|
+
`literal: "draft"`,
|
|
325
|
+
`literal: "published"`,
|
|
326
|
+
)
|
|
327
|
+
assertNotContains(t, got, `literal: "not-from-the-node"`)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
func TestMetadataCallNamesIncludeValidatedDeserialize(t *testing.T) {
|
|
331
|
+
for _, name := range []string{"deserialize", "validate", "validatedDeserialize", "typeOf"} {
|
|
332
|
+
if !isMetadataCallName(name) {
|
|
333
|
+
t.Fatalf("%s should be collected as a metadata call", name)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for _, name := range []string{"cast", "assert", "is"} {
|
|
337
|
+
if isMetadataCallName(name) {
|
|
338
|
+
t.Fatalf("collision-prone compatibility helper %s must require explicit metadata", name)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
func TestCompatibilityMetadataCallsRequireFoundationImports(t *testing.T) {
|
|
344
|
+
for _, ref := range []importRef{
|
|
345
|
+
{spec: foundationPackageSpec, exportName: "assert"},
|
|
346
|
+
{source: "/workspace/ts-server-foundation/src/index", spec: "../src", exportName: "is"},
|
|
347
|
+
{source: "/workspace/ts-server-foundation/src/reflection/conversion", spec: "./conversion", exportName: "cast"},
|
|
348
|
+
} {
|
|
349
|
+
if !isFoundationCompatibilityImport(ref) {
|
|
350
|
+
t.Fatalf("foundation compatibility import was not recognized: %#v", ref)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
for _, ref := range []importRef{
|
|
355
|
+
{spec: "node:assert/strict", exportName: "assert"},
|
|
356
|
+
{spec: "@scope/application-helpers", exportName: "is"},
|
|
357
|
+
{source: "/workspace/application/cast", spec: "./cast", exportName: "cast"},
|
|
358
|
+
{source: "/workspace/application/src/index", spec: "../src", exportName: "cast"},
|
|
359
|
+
} {
|
|
360
|
+
if isFoundationCompatibilityImport(ref) {
|
|
361
|
+
t.Fatalf("non-foundation compatibility import was recognized: %#v", ref)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
func TestReceiveTypeMethodCandidatesIncludeStaticMethods(t *testing.T) {
|
|
367
|
+
info, reg := testTypeInfo()
|
|
368
|
+
info.classes = []*classInfo{{
|
|
369
|
+
name: "FiltersHelpers",
|
|
370
|
+
staticMethods: []methodInfo{{
|
|
371
|
+
name: "extractFilters",
|
|
372
|
+
typeParams: []string{"T"},
|
|
373
|
+
params: []paramInfo{{name: "input", typeText: "string"}, {name: "type", typeText: "ReceiveType<T>"}},
|
|
374
|
+
}},
|
|
375
|
+
}}
|
|
376
|
+
reg.files[info.moduleKey] = info
|
|
377
|
+
|
|
378
|
+
if got := receiveTypeMethodCandidates(reg)["extractFilters"]; len(got) != 1 || got[0].owner != "FiltersHelpers" {
|
|
379
|
+
t.Fatalf("static receive-type candidates = %#v", got)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
func TestCollectRegistryHandlesReceiveTypeCallsToComputedMethods(t *testing.T) {
|
|
384
|
+
root := t.TempDir()
|
|
385
|
+
if err := os.WriteFile(filepath.Join(root, "tsconfig.json"), []byte(`{
|
|
386
|
+
"compilerOptions": {"strict": true, "target": "ESNext"},
|
|
387
|
+
"include": ["*.ts"],
|
|
388
|
+
"reflection": true
|
|
389
|
+
}`), 0o600); err != nil {
|
|
390
|
+
t.Fatal(err)
|
|
391
|
+
}
|
|
392
|
+
if err := os.WriteFile(filepath.Join(root, "source.ts"), []byte(`
|
|
393
|
+
type ReceiveType<T> = { readonly type?: T };
|
|
394
|
+
const method = 'receive';
|
|
395
|
+
|
|
396
|
+
class Receiver {
|
|
397
|
+
[method]<T>(value: T, type?: ReceiveType<T>): T {
|
|
398
|
+
return value;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
new Receiver()[method]<{ id: string }>({ id: 'one' });
|
|
403
|
+
`), 0o600); err != nil {
|
|
404
|
+
t.Fatal(err)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
program, diagnostics, err := driver.LoadProgram(root, "tsconfig.json", driver.LoadProgramOptions{SingleThreaded: true})
|
|
408
|
+
if err != nil {
|
|
409
|
+
t.Fatal(err)
|
|
410
|
+
}
|
|
411
|
+
if len(diagnostics) != 0 {
|
|
412
|
+
t.Fatalf("unexpected diagnostics: %v", diagnostics)
|
|
413
|
+
}
|
|
414
|
+
defer func() { _ = program.Close() }()
|
|
415
|
+
|
|
416
|
+
reg := collectRegistry(program, root, true, true)
|
|
417
|
+
var info *fileInfo
|
|
418
|
+
for _, candidate := range reg.files {
|
|
419
|
+
if candidate != nil && candidate.file != nil && filepath.Base(candidate.file.FileName()) == "source.ts" {
|
|
420
|
+
info = candidate
|
|
421
|
+
break
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if info == nil {
|
|
425
|
+
t.Fatal("source.ts was not collected")
|
|
426
|
+
}
|
|
427
|
+
if len(info.calls) != 1 {
|
|
428
|
+
t.Fatalf("receive-type calls = %#v", info.calls)
|
|
429
|
+
}
|
|
430
|
+
if info.calls[0].metadataArgIndex != 1 || info.calls[0].typeText != "{ id: string }" {
|
|
431
|
+
t.Fatalf("receive-type call = %#v", info.calls[0])
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
func TestReceiveTypeArgumentAcceptsQualifiedImports(t *testing.T) {
|
|
436
|
+
for _, input := range []string{
|
|
437
|
+
"ReceiveType<T>",
|
|
438
|
+
"reflection.ReceiveType<T>",
|
|
439
|
+
`import("@zyno-io/ts-server-foundation").ReceiveType<T>`,
|
|
440
|
+
} {
|
|
441
|
+
got, ok := receiveTypeArgument(input)
|
|
442
|
+
if !ok || got != "T" {
|
|
443
|
+
t.Fatalf("receiveTypeArgument(%q) = %q, %v", input, got, ok)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
func TestExternalPackageRootUsesMaterializedPnpPackage(t *testing.T) {
|
|
449
|
+
root := t.TempDir()
|
|
450
|
+
materialized := filepath.Join(root, "materialized-package")
|
|
451
|
+
if err := os.MkdirAll(materialized, 0o755); err != nil {
|
|
452
|
+
t.Fatal(err)
|
|
453
|
+
}
|
|
454
|
+
reg := ®istry{externalPackageRoots: map[string]string{"@fixture/receive-types": materialized}}
|
|
455
|
+
got := externalPackageRoot(filepath.Join(root, "src", "service.ts"), "@fixture/receive-types/client", reg)
|
|
456
|
+
if got != materialized {
|
|
457
|
+
t.Fatalf("externalPackageRoot() = %q, want %q", got, materialized)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
func TestPropertiesFromBodyParsesFieldsAndIgnoresMembers(t *testing.T) {
|
|
462
|
+
body := `
|
|
463
|
+
id: string;
|
|
464
|
+
optional?: number
|
|
465
|
+
tuple: [string, number]
|
|
466
|
+
method(value: string): void
|
|
467
|
+
[key: string]: boolean
|
|
468
|
+
// comment: ignored
|
|
469
|
+
nested: { count: number; label: string }
|
|
470
|
+
`
|
|
471
|
+
got := propertiesFromBody(body)
|
|
472
|
+
want := []utilityProperty{
|
|
473
|
+
{name: "id", typeText: "string"},
|
|
474
|
+
{name: "optional", typeText: "number", optional: true},
|
|
475
|
+
{name: "tuple", typeText: "[string, number]"},
|
|
476
|
+
{name: "nested", typeText: "{ count: number; label: string }"},
|
|
477
|
+
}
|
|
478
|
+
if !reflect.DeepEqual(got, want) {
|
|
479
|
+
t.Fatalf("propertiesFromBody() = %#v, want %#v", got, want)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
func TestTypeExprRendersObjectLiteralsAndUtilityTypes(t *testing.T) {
|
|
484
|
+
info, reg := testTypeInfo()
|
|
485
|
+
info.aliases["User"] = aliasInfo{body: "{ id: string; email?: string; count: number }"}
|
|
486
|
+
|
|
487
|
+
objectExpr := typeExpr(info, reg, "{ id: string; count?: number; [key: string]: boolean }")
|
|
488
|
+
for _, want := range []string{
|
|
489
|
+
"kind: 18",
|
|
490
|
+
"index: {kind: 8}",
|
|
491
|
+
"name: \"id\", type: {kind: 6}",
|
|
492
|
+
"name: \"count\", type: {kind: 12, types: [{kind: 7}, {kind: 4}]}, optional: true",
|
|
493
|
+
} {
|
|
494
|
+
if !strings.Contains(objectExpr, want) {
|
|
495
|
+
t.Fatalf("object expression %q does not contain %q", objectExpr, want)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
pickExpr := typeExpr(info, reg, "Pick<User, 'id' | 'email'>")
|
|
500
|
+
for _, want := range []string{
|
|
501
|
+
"typeName: \"Pick\"",
|
|
502
|
+
"utilityType: \"Pick\"",
|
|
503
|
+
"utilityKeys: [\"id\", \"email\"]",
|
|
504
|
+
"name: \"id\", type: {kind: 6}",
|
|
505
|
+
"name: \"email\", type: {kind: 12, types: [{kind: 6}, {kind: 4}]}, optional: true",
|
|
506
|
+
} {
|
|
507
|
+
if !strings.Contains(pickExpr, want) {
|
|
508
|
+
t.Fatalf("pick expression %q does not contain %q", pickExpr, want)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
lengthExpr := typeExpr(info, reg, "Length<4>")
|
|
513
|
+
assertContainsAll(t, lengthExpr,
|
|
514
|
+
`kind: 13, typeName: "Length"`,
|
|
515
|
+
`kind: 6`,
|
|
516
|
+
`typeName: "MinLength", validation: [{name: "minLength", args: [{kind: 10, literal: 4}]}]`,
|
|
517
|
+
`typeName: "MaxLength", validation: [{name: "maxLength", args: [{kind: 10, literal: 4}]}]`,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
unionReturnExpr := typeExpr(info, reg, "Promise<{ result: 'token' } | { result: 'login'; jwt: string }>")
|
|
521
|
+
assertContainsAll(t, unionReturnExpr,
|
|
522
|
+
"kind: 22",
|
|
523
|
+
"literal: \"token\"",
|
|
524
|
+
"literal: \"login\"",
|
|
525
|
+
"name: \"jwt\", type: {kind: 6}",
|
|
526
|
+
)
|
|
527
|
+
assertNotContains(t, unionReturnExpr, "literal: 'token'")
|
|
528
|
+
assertNotContains(t, unionReturnExpr, "typeName: \"{ result:")
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
func TestTypeExprRendersUtilityTypeMatrix(t *testing.T) {
|
|
532
|
+
info, reg := testTypeInfo()
|
|
533
|
+
info.aliases["User"] = aliasInfo{body: "{ id: string; email?: string; count: number; active: boolean }"}
|
|
534
|
+
info.aliases["Container"] = aliasInfo{body: "{ items: Array<{ id: string; count: number }>; maybe: { enabled: boolean } | null }"}
|
|
535
|
+
|
|
536
|
+
omitExpr := typeExpr(info, reg, "Omit<User, 'email' | 'active'>")
|
|
537
|
+
assertContainsAll(t, omitExpr,
|
|
538
|
+
"typeName: \"Omit\"",
|
|
539
|
+
"name: \"id\", type: {kind: 6}",
|
|
540
|
+
"name: \"count\", type: {kind: 7}",
|
|
541
|
+
"types: [{kind: 20, name: \"id\", type: {kind: 6}, optional: false}, {kind: 20, name: \"count\", type: {kind: 7}, optional: false}]",
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
partialExpr := typeExpr(info, reg, "Partial<Pick<User, 'id' | 'count'>>")
|
|
545
|
+
assertContainsAll(t, partialExpr,
|
|
546
|
+
"typeName: \"Partial\"",
|
|
547
|
+
"name: \"id\", type: {kind: 12, types: [{kind: 6}, {kind: 4}]}, optional: true",
|
|
548
|
+
"name: \"count\", type: {kind: 12, types: [{kind: 7}, {kind: 4}]}, optional: true",
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
requiredExpr := typeExpr(info, reg, "Required<Pick<User, 'email'>>")
|
|
552
|
+
assertContainsAll(t, requiredExpr,
|
|
553
|
+
"typeName: \"Required\"",
|
|
554
|
+
"name: \"email\", type: {kind: 6}, optional: false",
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
recordExpr := typeExpr(info, reg, "Record<'email' | 'phone', string | null>")
|
|
558
|
+
assertContainsAll(t, recordExpr,
|
|
559
|
+
"typeName: \"Record\"",
|
|
560
|
+
"utilityType: \"Record\"",
|
|
561
|
+
"typeArguments: [{kind: 12, types: [{kind: 10, literal: \"email\"}, {kind: 10, literal: \"phone\"}]}, {kind: 12, types: [{kind: 6}, {kind: 5}]}]",
|
|
562
|
+
"index: {kind: 12, types: [{kind: 6}, {kind: 5}]}",
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
extractExpr := typeExpr(info, reg, "Extract<{ type: 'blank' } | { type: 'webView'; url: string } | { type: 'mediaRef'; mediaId: string }, { type: 'blank' } | { type: 'webView' }>")
|
|
566
|
+
assertContainsAll(t, extractExpr,
|
|
567
|
+
"typeName: \"Extract\"",
|
|
568
|
+
"utilityType: \"Extract\"",
|
|
569
|
+
"literal: \"mediaRef\"",
|
|
570
|
+
"literal: \"webView\"",
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
indexedExpr := typeExpr(info, reg, "Pick<Container['items'][number], 'id'>")
|
|
574
|
+
assertContainsAll(t, indexedExpr,
|
|
575
|
+
"typeName: \"Pick\"",
|
|
576
|
+
"name: \"id\", type: {kind: 6}",
|
|
577
|
+
"types: [{kind: 20, name: \"id\", type: {kind: 6}, optional: false}]",
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
markerIntersectionExpr := typeExpr(info, reg, "Pick<User & TypeAnnotation<'example:marker'>, 'id'>")
|
|
581
|
+
assertContainsAll(t, markerIntersectionExpr,
|
|
582
|
+
"typeName: \"Pick\"",
|
|
583
|
+
"name: \"id\", type: {kind: 6}",
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
func TestShouldUseTypiaTypeForNullableMappedAliases(t *testing.T) {
|
|
588
|
+
info, reg := testTypeInfo()
|
|
589
|
+
info.aliases["NullableKeys"] = aliasInfo{
|
|
590
|
+
body: "{ [K in keyof T]-?: null extends T[K] ? K : never }[keyof T]",
|
|
591
|
+
params: []string{"T"},
|
|
592
|
+
}
|
|
593
|
+
info.aliases["NullableOptionals"] = aliasInfo{
|
|
594
|
+
body: "Omit<T, NullableKeys<T>> & Partial<Pick<T, NullableKeys<T>>>",
|
|
595
|
+
params: []string{"T"},
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if !shouldUseTypiaType(info, reg, "NullableOptionals<Pick<User, 'name' | 'color'>>") {
|
|
599
|
+
t.Fatal("nullable mapped alias should route through Typia metadata")
|
|
600
|
+
}
|
|
601
|
+
if !canPreferTypiaType(info, reg, "NullableOptionals<Pick<User, 'name' | 'color'>>") {
|
|
602
|
+
t.Fatal("plain nullable mapped alias should be safe for Typia metadata")
|
|
603
|
+
}
|
|
604
|
+
if !receiveTypeMetadataResolvable(info, reg, "NullableOptionals<Pick<User, 'name' | 'color'>>") {
|
|
605
|
+
t.Fatal("nullable mapped alias should be accepted by receive-type metadata preflight")
|
|
606
|
+
}
|
|
607
|
+
if shouldUseTypiaType(info, reg, "Partial<Pick<User, 'name'>>") {
|
|
608
|
+
t.Fatal("ordinary utility type should stay on the internal encoder")
|
|
609
|
+
}
|
|
610
|
+
if !canPreferTypiaType(info, reg, "Partial<Pick<User, 'name'>>") {
|
|
611
|
+
t.Fatal("ordinary utility type should be safe when a preferred metadata surface requests Typia")
|
|
612
|
+
}
|
|
613
|
+
if canPreferTypiaType(info, reg, "HttpBody<NullableOptionals<Pick<User, 'name' | 'color'>>>") {
|
|
614
|
+
t.Fatal("outer HTTP marker should be preserved by the internal encoder")
|
|
615
|
+
}
|
|
616
|
+
if canPreferTypiaType(info, reg, "string & DatabaseField<{ type: 'CHAR(36)' }>") {
|
|
617
|
+
t.Fatal("database markers should stay on the internal encoder")
|
|
618
|
+
}
|
|
619
|
+
if canPreferTypiaType(info, reg, "string & TypiaFormat<'date'> & TsfDatabaseFieldTag<{ type: 'DATE' }> & TsfTypeTag<'string', 'date'>") {
|
|
620
|
+
t.Fatal("database field tags should stay on the internal encoder even when combined with Typia-compatible tags")
|
|
621
|
+
}
|
|
622
|
+
if !canPreferTypiaType(info, reg, "string & MinLength<2>") {
|
|
623
|
+
t.Fatal("Typia-compatible validation marker should be safe for Typia metadata")
|
|
624
|
+
}
|
|
625
|
+
if !canPreferTypiaType(info, reg, "number & GreaterThan<0>") {
|
|
626
|
+
t.Fatal("Typia-compatible greater-than marker should be safe for Typia metadata")
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
info.aliases["UserWithDatabaseField"] = aliasInfo{body: "{ name: string; color: string | null; note: string & DatabaseField<{ type: 'TEXT' }> | null; status: 'active' | 'inactive'; createdAt: Date }"}
|
|
630
|
+
if !canPreferTypiaTypeOnPreferredSurface(info, reg, "NullableOptionals<Pick<UserWithDatabaseField, 'name' | 'color' | 'note'>>") {
|
|
631
|
+
t.Fatal("preferred structural metadata should allow Typia to resolve nested database tags")
|
|
632
|
+
}
|
|
633
|
+
info.imports["FoundationUtility"] = importRef{source: "", exportName: "FoundationUtility", spec: foundationPackageSpec}
|
|
634
|
+
if !canPreferTypiaTypeOnPreferredSurface(info, reg, "FoundationUtility<Pick<UserWithDatabaseField, 'name' | 'color' | 'note' | 'status'>>") {
|
|
635
|
+
t.Fatal("imported foundation utility helpers should be resolved by Typia on preferred metadata surfaces")
|
|
636
|
+
}
|
|
637
|
+
if !canPreferTypiaTypeOnPreferredSurface(info, reg, "Pick<UserWithDatabaseField, 'createdAt'>") {
|
|
638
|
+
t.Fatal("selected Date properties should be rendered by Typia on preferred metadata surfaces")
|
|
639
|
+
}
|
|
640
|
+
if canPreferTypiaTypeOnPreferredSurface(info, reg, "HttpBody<NullableOptionals<Pick<UserWithDatabaseField, 'name' | 'color' | 'note'>>>") {
|
|
641
|
+
t.Fatal("outer HTTP marker should still be preserved on preferred metadata surfaces")
|
|
642
|
+
}
|
|
643
|
+
if canPreferTypiaTypeOnPreferredSurface(info, reg, "string & DatabaseField<{ type: 'CHAR(36)' }>") {
|
|
644
|
+
t.Fatal("direct database marker payload should stay on the internal encoder")
|
|
645
|
+
}
|
|
646
|
+
if !canPreferTypiaTypeOnPreferredSurface(info, reg, "Record<string, string>") {
|
|
647
|
+
t.Fatal("Record/index metadata should be resolved by Typia on preferred structural surfaces")
|
|
648
|
+
}
|
|
649
|
+
info.aliases["IndexedAccessSource"] = aliasInfo{body: "{ items: Array<{ id: string; label: string }> }"}
|
|
650
|
+
if !canPreferTypiaTypeOnPreferredSurface(info, reg, "IndexedAccessSource['items'][number]") {
|
|
651
|
+
t.Fatal("indexed access should be resolved by Typia on preferred structural surfaces")
|
|
652
|
+
}
|
|
653
|
+
info.aliases["IndexedAccessLiteralSource"] = aliasInfo{body: "{ status: 'open' | 'closed' | 'voided' }"}
|
|
654
|
+
if !sourceTypeNeedsInternalPropertyMetadata(info, reg, "IndexedAccessLiteralSource['status'] | null", &typeContext{seen: map[string]bool{}}, map[string]bool{}) {
|
|
655
|
+
t.Fatal("indexed access to literal unions should preserve source union order at property level")
|
|
656
|
+
}
|
|
657
|
+
info.interfaces["IndexedAccessParams"] = []interfaceInfo{{properties: []utilityProperty{{name: "platform", typeText: "IndexedAccessLiteralSource['status']"}}, pos: 1}}
|
|
658
|
+
if !sourceTypeNeedsInternalPropertyMetadata(info, reg, "IndexedAccessParams['platform']", &typeContext{seen: map[string]bool{}}, map[string]bool{}) {
|
|
659
|
+
t.Fatal("indexed access chains to literal unions should preserve source union order at property level")
|
|
660
|
+
}
|
|
661
|
+
serviceInfo := &fileInfo{
|
|
662
|
+
moduleKey: "test/service",
|
|
663
|
+
aliases: map[string]aliasInfo{},
|
|
664
|
+
interfaces: map[string][]interfaceInfo{"CreateParams": {{properties: []utilityProperty{{name: "platform", typeText: "DeviceEntity['platform']"}}, pos: 1}}},
|
|
665
|
+
enums: map[string]enumInfo{},
|
|
666
|
+
classes: []*classInfo{{name: "DeviceEntity", properties: []propertyInfo{{name: "platform", typeText: "'ios' | 'android'"}}}},
|
|
667
|
+
functions: map[string][]functionInfo{},
|
|
668
|
+
imports: map[string]importRef{},
|
|
669
|
+
reexports: map[string]importRef{},
|
|
670
|
+
}
|
|
671
|
+
info.imports["CreateParams"] = importRef{source: "test/service", exportName: "CreateParams", spec: "test/service"}
|
|
672
|
+
reg.files[serviceInfo.moduleKey] = serviceInfo
|
|
673
|
+
reg.byPath[serviceInfo.moduleKey] = serviceInfo
|
|
674
|
+
if !sourceTypeNeedsInternalPropertyMetadata(info, reg, "CreateParams['platform']", &typeContext{seen: map[string]bool{}}, map[string]bool{}) {
|
|
675
|
+
t.Fatal("cross-file indexed access chains to literal unions should preserve source union order at property level")
|
|
676
|
+
}
|
|
677
|
+
info.interfaces["RequestWithIndexedPlatform"] = []interfaceInfo{{properties: []utilityProperty{{name: "platform", typeText: "CreateParams['platform']"}}, pos: 1}}
|
|
678
|
+
override, ok := typiaSourcePropertyOverrideExpr(info, reg, "RequestWithIndexedPlatform", "platform", 10)
|
|
679
|
+
if !ok {
|
|
680
|
+
t.Fatal("cross-file indexed access chains should trigger property-level internal metadata")
|
|
681
|
+
}
|
|
682
|
+
assertContainsAll(t, override, "literal: \"ios\"", "literal: \"android\"")
|
|
683
|
+
objectOverride, ok := typiaNamedObjectInternalOverrideExpr(info, reg, "RequestWithIndexedPlatform", 10)
|
|
684
|
+
if !ok {
|
|
685
|
+
t.Fatal("named objects with indexed access properties should use internal metadata")
|
|
686
|
+
}
|
|
687
|
+
iosIndex := strings.Index(objectOverride, "literal: \"ios\"")
|
|
688
|
+
androidIndex := strings.Index(objectOverride, "literal: \"android\"")
|
|
689
|
+
if iosIndex < 0 || androidIndex < 0 || iosIndex > androidIndex {
|
|
690
|
+
t.Fatalf("object override did not preserve literal order: %s", objectOverride)
|
|
691
|
+
}
|
|
692
|
+
preferredNamed, ok := preferredNamedInterfaceTypeExpr(info, reg, "RequestWithIndexedPlatform", 10)
|
|
693
|
+
if !ok {
|
|
694
|
+
t.Fatal("preferred named interface path should preserve indexed-access literal metadata")
|
|
695
|
+
}
|
|
696
|
+
iosIndex = strings.Index(preferredNamed, "literal: \"ios\"")
|
|
697
|
+
androidIndex = strings.Index(preferredNamed, "literal: \"android\"")
|
|
698
|
+
if iosIndex < 0 || androidIndex < 0 || iosIndex > androidIndex {
|
|
699
|
+
t.Fatalf("preferred named interface path did not preserve literal order: %s", preferredNamed)
|
|
700
|
+
}
|
|
701
|
+
entityInfo := &fileInfo{
|
|
702
|
+
moduleKey: "test/entity",
|
|
703
|
+
aliases: map[string]aliasInfo{},
|
|
704
|
+
interfaces: map[string][]interfaceInfo{},
|
|
705
|
+
enums: map[string]enumInfo{},
|
|
706
|
+
classes: []*classInfo{{name: "ImportedDeviceEntity", properties: []propertyInfo{{name: "platform", typeText: "'ios' | 'android'"}}}},
|
|
707
|
+
functions: map[string][]functionInfo{},
|
|
708
|
+
imports: map[string]importRef{},
|
|
709
|
+
reexports: map[string]importRef{},
|
|
710
|
+
}
|
|
711
|
+
importedServiceInfo := &fileInfo{
|
|
712
|
+
moduleKey: "test/imported-service",
|
|
713
|
+
aliases: map[string]aliasInfo{},
|
|
714
|
+
interfaces: map[string][]interfaceInfo{"ImportedCreateParams": {{
|
|
715
|
+
properties: []utilityProperty{{name: "platform", typeText: "ImportedDeviceEntity['platform']"}},
|
|
716
|
+
pos: 1,
|
|
717
|
+
}}},
|
|
718
|
+
enums: map[string]enumInfo{},
|
|
719
|
+
classes: []*classInfo{},
|
|
720
|
+
functions: map[string][]functionInfo{},
|
|
721
|
+
imports: map[string]importRef{"ImportedDeviceEntity": {source: "test/entity", exportName: "ImportedDeviceEntity", spec: "test/entity"}},
|
|
722
|
+
reexports: map[string]importRef{},
|
|
723
|
+
}
|
|
724
|
+
info.imports["ImportedCreateParams"] = importRef{source: "test/imported-service", exportName: "ImportedCreateParams", spec: "test/imported-service"}
|
|
725
|
+
reg.files[entityInfo.moduleKey] = entityInfo
|
|
726
|
+
reg.byPath[entityInfo.moduleKey] = entityInfo
|
|
727
|
+
reg.files[importedServiceInfo.moduleKey] = importedServiceInfo
|
|
728
|
+
reg.byPath[importedServiceInfo.moduleKey] = importedServiceInfo
|
|
729
|
+
info.interfaces["RequestWithImportedIndexedPlatform"] = []interfaceInfo{{properties: []utilityProperty{{name: "platform", typeText: "ImportedCreateParams['platform']"}}, pos: 1}}
|
|
730
|
+
if !sourceTypeNeedsInternalPropertyMetadata(info, reg, "ImportedCreateParams['platform']", &typeContext{seen: map[string]bool{}}, map[string]bool{}) {
|
|
731
|
+
t.Fatal("imported indexed access chains to imported class literal unions should preserve source order")
|
|
732
|
+
}
|
|
733
|
+
preferredNamed, ok = preferredNamedInterfaceTypeExpr(info, reg, "RequestWithImportedIndexedPlatform", 10)
|
|
734
|
+
if !ok {
|
|
735
|
+
t.Fatal("preferred named interface path should preserve imported indexed-access literal metadata")
|
|
736
|
+
}
|
|
737
|
+
iosIndex = strings.Index(preferredNamed, "literal: \"ios\"")
|
|
738
|
+
androidIndex = strings.Index(preferredNamed, "literal: \"android\"")
|
|
739
|
+
if iosIndex < 0 || androidIndex < 0 || iosIndex > androidIndex {
|
|
740
|
+
t.Fatalf("preferred imported named interface path did not preserve literal order: %s", preferredNamed)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
info.aliases["DateString"] = aliasInfo{body: "string & TypiaFormat<'date'> & TsfTypeTag<'string', 'date'>"}
|
|
744
|
+
info.aliases["UuidString"] = aliasInfo{body: "string & TypiaFormat<'uuid'> & TsfTypeTag<'string', 'uuidString'>"}
|
|
745
|
+
if canPreferTypiaType(info, reg, "DateString | UuidString") {
|
|
746
|
+
t.Fatal("tagged string unions should stay on the internal encoder to preserve alternatives")
|
|
747
|
+
}
|
|
748
|
+
if canPreferTypiaTypeOnPreferredSurface(info, reg, "DateString | UuidString") {
|
|
749
|
+
t.Fatal("tagged string unions should stay on the internal encoder on preferred metadata surfaces")
|
|
750
|
+
}
|
|
751
|
+
if canPreferTypiaType(info, reg, "{ startsAt: Date }") {
|
|
752
|
+
t.Fatal("Date should stay on the internal encoder to preserve runtime Date deserialization")
|
|
753
|
+
}
|
|
754
|
+
if !canPreferTypiaTypeOnPreferredSurface(info, reg, "{ startsAt: Date }") {
|
|
755
|
+
t.Fatal("Date should not force a whole object onto the internal encoder on preferred metadata surfaces")
|
|
756
|
+
}
|
|
757
|
+
if canPreferTypiaType(info, reg, "Date & TsfValidatorTag<'object', 'validDate'>") {
|
|
758
|
+
t.Fatal("Date validator intersections should stay on the internal encoder to preserve Date semantics")
|
|
759
|
+
}
|
|
760
|
+
if canPreferTypiaTypeOnPreferredSurface(info, reg, "{ startsAt: Date & TsfValidatorTag<'object', 'validDate'> }") {
|
|
761
|
+
t.Fatal("object-target validator tags should stay on the internal encoder because Typia does not preserve them on Date metadata")
|
|
762
|
+
}
|
|
763
|
+
info.interfaces["AnnotatedDto"] = []interfaceInfo{{body: "name: string & MinLength<2>; count: number"}}
|
|
764
|
+
if !canPreferTypiaType(info, reg, "AnnotatedDto") {
|
|
765
|
+
t.Fatal("referenced DTO with Typia-compatible validation should be safe for Typia metadata")
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
func TestPreferredInterfacePreservesNamedAliasProperties(t *testing.T) {
|
|
770
|
+
info, reg := testTypeInfo()
|
|
771
|
+
info.aliases["NamedDetails"] = aliasInfo{
|
|
772
|
+
body: "Partial<Pick<SourceEntity, 'left' | 'right'>>",
|
|
773
|
+
metadataText: "{kind: 18, typeName: \"NamedDetails\", types: [{kind: 20, name: \"left\", type: {kind: 6}, optional: true}]}",
|
|
774
|
+
}
|
|
775
|
+
info.interfaces["CreateRequest"] = []interfaceInfo{{
|
|
776
|
+
body: "name: string; details?: NamedDetails; optionalDetails?: NamedDetails | null",
|
|
777
|
+
properties: []utilityProperty{
|
|
778
|
+
{name: "name", typeText: "string"},
|
|
779
|
+
{name: "details", typeText: "NamedDetails", optional: true},
|
|
780
|
+
{name: "optionalDetails", typeText: "NamedDetails | null", optional: true},
|
|
781
|
+
},
|
|
782
|
+
}}
|
|
783
|
+
|
|
784
|
+
expr := typeExprForNodePreferred(info, reg, "CreateRequest", nil, 0, true)
|
|
785
|
+
assertContainsAll(t, expr,
|
|
786
|
+
"typeName: \"CreateRequest\"",
|
|
787
|
+
"name: \"details\"",
|
|
788
|
+
"name: \"optionalDetails\"",
|
|
789
|
+
"typeName: \"NamedDetails\"",
|
|
790
|
+
)
|
|
791
|
+
if strings.Contains(expr, "Partial<Pick") {
|
|
792
|
+
t.Fatalf("preferred interface expression leaked utility display name: %s", expr)
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
func TestTypeExprRendersFoundationAnnotations(t *testing.T) {
|
|
797
|
+
info, reg := testTypeInfo()
|
|
798
|
+
|
|
799
|
+
got := typeExpr(info, reg, "string & MinLength<2> & TypeAnnotation<'tsf:type', 'custom'>")
|
|
800
|
+
for _, want := range []string{
|
|
801
|
+
"kind: 13",
|
|
802
|
+
"typeName: \"MinLength\"",
|
|
803
|
+
"validation: [{name: \"minLength\", args: [{kind: 10, literal: 2}]}]",
|
|
804
|
+
"annotations: {\"tsf:type\": {kind: 10, literal: \"custom\"}}",
|
|
805
|
+
} {
|
|
806
|
+
if !strings.Contains(got, want) {
|
|
807
|
+
t.Fatalf("annotation expression %q does not contain %q", got, want)
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
got = typeExpr(info, reg, "Date & TsfValidatorTag<'object', 'validDate'>")
|
|
812
|
+
assertContainsAll(t, got,
|
|
813
|
+
"typeName: \"Date\"",
|
|
814
|
+
"classType: () =>",
|
|
815
|
+
"typeName: \"Validator\"",
|
|
816
|
+
"validation: [{name: \"validator\", args: [{kind: 10, literal: \"validDate\"}]}]",
|
|
817
|
+
)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
func TestTypiaSourcePropertyOverridePreservesDateProperties(t *testing.T) {
|
|
821
|
+
info, reg := testTypeInfo()
|
|
822
|
+
info.aliases["DateString"] = aliasInfo{body: "string & TypiaFormat<'date'> & TsfTypeTag<'string', 'date'>"}
|
|
823
|
+
info.aliases["NamedSchedule"] = aliasInfo{
|
|
824
|
+
body: "Pick<EntitySource, 'id' | 'createdAt'>",
|
|
825
|
+
metadataText: "{kind: 18, typeName: \"NamedSchedule\", types: [{kind: 20, name: \"id\", type: {kind: 13}, optional: false}, {kind: 20, name: \"createdAt\", type: {kind: 16, typeName: \"Date\", classType: () => Date}, optional: false}]}",
|
|
826
|
+
}
|
|
827
|
+
info.aliases["EntityPick"] = aliasInfo{body: "Pick<EntitySource, 'id' | 'createdAt' | 'deletedAt'>"}
|
|
828
|
+
info.classes = append(info.classes, &classInfo{
|
|
829
|
+
name: "EntitySource",
|
|
830
|
+
properties: []propertyInfo{
|
|
831
|
+
{name: "id", typeText: "string"},
|
|
832
|
+
{name: "createdAt", typeText: "Date"},
|
|
833
|
+
{name: "deletedAt", typeText: "Date | null"},
|
|
834
|
+
},
|
|
835
|
+
})
|
|
836
|
+
info.interfaces["BaseResponse"] = []interfaceInfo{{body: "updatedAt: Date | null; birthday: DateString"}}
|
|
837
|
+
info.interfaces["Response"] = []interfaceInfo{{body: "createdAt: Date", extends: []string{"BaseResponse"}}}
|
|
838
|
+
info.interfaces["ScheduleResponse"] = []interfaceInfo{{body: "schedules: NamedSchedule[]"}}
|
|
839
|
+
info.interfaces["CreateResponse"] = []interfaceInfo{{body: "key: string", extends: []string{"EntityPick"}}}
|
|
840
|
+
|
|
841
|
+
assertTypiaPropertyOverride := func(source string, property string, want ...string) {
|
|
842
|
+
t.Helper()
|
|
843
|
+
got, ok := typiaSourcePropertyOverrideExpr(info, reg, source, property, 0)
|
|
844
|
+
if !ok {
|
|
845
|
+
t.Fatalf("expected Date override for %s.%s", source, property)
|
|
846
|
+
}
|
|
847
|
+
assertContainsAll(t, got, want...)
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
assertTypiaPropertyOverride("Response", "createdAt", "typeName: \"Date\"", "classType: () =>")
|
|
851
|
+
assertTypiaPropertyOverride("Response", "updatedAt", "typeName: \"Date\"", "{kind: 5}")
|
|
852
|
+
assertTypiaPropertyOverride("EntityPick", "createdAt", "typeName: \"Date\"", "classType: () =>")
|
|
853
|
+
assertTypiaPropertyOverride("EntityPick", "deletedAt", "typeName: \"Date\"", "{kind: 5}")
|
|
854
|
+
assertTypiaPropertyOverride("Promise<Response[]>", "updatedAt", "typeName: \"Date\"", "{kind: 5}")
|
|
855
|
+
assertTypiaPropertyOverride("CreateResponse", "createdAt", "typeName: \"Date\"", "classType: () =>")
|
|
856
|
+
assertTypiaPropertyOverride("CreateResponse", "deletedAt", "typeName: \"Date\"", "{kind: 5}")
|
|
857
|
+
if !sourceTypeIsDateRootType(info, reg, "Date | null", &typeContext{seen: map[string]bool{}}, map[string]bool{}) {
|
|
858
|
+
t.Fatal("Date nullish roots should stay on the internal encoder")
|
|
859
|
+
}
|
|
860
|
+
if sourceTypeIsDateRootType(info, reg, "Response", &typeContext{seen: map[string]bool{}}, map[string]bool{}) {
|
|
861
|
+
t.Fatal("DTOs containing Date should not force whole-object internal encoding")
|
|
862
|
+
}
|
|
863
|
+
if got, ok := preferredDateRootTypeExpr(info, reg, "Date | null", nil, 0); !ok {
|
|
864
|
+
t.Fatal("preferred Date roots should render through the internal encoder")
|
|
865
|
+
} else {
|
|
866
|
+
assertContainsAll(t, got, "typeName: \"Date\"", "{kind: 5}")
|
|
867
|
+
}
|
|
868
|
+
assertContainsAll(t,
|
|
869
|
+
typeExprForNodePreferred(info, reg, "Promise<Response[]>", nil, 0, true),
|
|
870
|
+
"kind: 22",
|
|
871
|
+
"kind: 14",
|
|
872
|
+
"typeName: \"Response\"",
|
|
873
|
+
"name: \"updatedAt\", type: {kind: 12, types: [{kind: 16, typeName: \"Date\"",
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
if got, ok := typiaSourcePropertyOverrideExpr(info, reg, "Response", "birthday", 0); !ok {
|
|
877
|
+
t.Fatal("named scalar aliases should preserve property source metadata")
|
|
878
|
+
} else {
|
|
879
|
+
assertContainsAll(t, got, "typeName: \"DateString\"")
|
|
880
|
+
}
|
|
881
|
+
if got, ok := typiaSourcePropertyOverrideExpr(info, reg, "ScheduleResponse", "schedules", 0); !ok {
|
|
882
|
+
t.Fatal("named array aliases should preserve property source metadata")
|
|
883
|
+
} else {
|
|
884
|
+
assertContainsAll(t, got, "kind: 14", "typeName: \"NamedSchedule\"")
|
|
885
|
+
}
|
|
886
|
+
info.aliases["MetadataString"] = aliasInfo{body: "string & DatabaseField<{ type: 'TEXT' }>"}
|
|
887
|
+
info.interfaces["MetadataResponse"] = []interfaceInfo{{body: "note: MetadataString"}}
|
|
888
|
+
if got, ok := typiaSourcePropertyOverrideExpr(info, reg, "MetadataResponse", "note", 0); !ok {
|
|
889
|
+
t.Fatal("database metadata aliases should preserve property source metadata")
|
|
890
|
+
} else {
|
|
891
|
+
assertContainsAll(t, got, "typeName: \"MetadataString\"", "typeName: \"DatabaseField\"", "database: {\"*\": {type: \"TEXT\"}}")
|
|
892
|
+
}
|
|
893
|
+
info.imports["ValidDate"] = importRef{spec: foundationPackageSpec, exportName: "ValidDate"}
|
|
894
|
+
if !sourceTypeContainsDateType(info, reg, "ValidDate", &typeContext{seen: map[string]bool{}}, map[string]bool{}) {
|
|
895
|
+
t.Fatal("foundation ValidDate should be preserved as Date property metadata")
|
|
896
|
+
}
|
|
897
|
+
if got, ok := typiaSourcePropertyOverrideExpr(info, reg, "__type", "updatedAt", 0, "Promise<Response[]>"); !ok {
|
|
898
|
+
t.Fatal("alternate root source should preserve Date metadata")
|
|
899
|
+
} else {
|
|
900
|
+
assertContainsAll(t, got, "typeName: \"Date\"", "{kind: 5}")
|
|
901
|
+
}
|
|
902
|
+
if got, ok := typiaSourcePropertyOverrideExpr(info, reg, "__type", "result", 0, "Promise<{ result: 'token' } | { result: 'login'; jwt: string }>"); ok {
|
|
903
|
+
t.Fatalf("object-union root source should not override branch properties: %s", got)
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
func TestPreferredExternalImportedAliasesUseRuntimeMetadata(t *testing.T) {
|
|
908
|
+
info, reg := testTypeInfo()
|
|
909
|
+
info.imports["ExternalStatus"] = importRef{spec: "@scope/shared", exportName: "ExternalStatus"}
|
|
910
|
+
info.imports["FoundationAlias"] = importRef{spec: foundationPackageSpec, exportName: "FoundationAlias"}
|
|
911
|
+
info.interfaces["Response"] = []interfaceInfo{{body: "status: ExternalStatus"}}
|
|
912
|
+
sharedInfo := &fileInfo{
|
|
913
|
+
moduleKey: "shared/status",
|
|
914
|
+
aliases: map[string]aliasInfo{"WorkspaceStatus": {body: "'ready' | 'busy'"}},
|
|
915
|
+
interfaces: map[string][]interfaceInfo{},
|
|
916
|
+
enums: map[string]enumInfo{},
|
|
917
|
+
classes: []*classInfo{},
|
|
918
|
+
functions: map[string][]functionInfo{},
|
|
919
|
+
imports: map[string]importRef{},
|
|
920
|
+
reexports: map[string]importRef{},
|
|
921
|
+
}
|
|
922
|
+
reg.files[sharedInfo.moduleKey] = sharedInfo
|
|
923
|
+
reg.byPath["/workspace/shared/status.ts"] = sharedInfo
|
|
924
|
+
info.imports["WorkspaceStatus"] = importRef{source: "/workspace/shared/status.ts", spec: "@scope/shared", exportName: "WorkspaceStatus"}
|
|
925
|
+
info.imports["HasDefault"] = importRef{spec: foundationPackageSpec, exportName: "HasDefault"}
|
|
926
|
+
info.imports["NullableMySQLCoordinate"] = importRef{spec: foundationPackageSpec, exportName: "NullableMySQLCoordinate"}
|
|
927
|
+
decl := info.interfaces["Response"][0]
|
|
928
|
+
|
|
929
|
+
if !interfaceNeedsPreferredSourceMetadata(info, reg, decl, map[string]bool{}) {
|
|
930
|
+
t.Fatal("interfaces containing external imported aliases should preserve source metadata")
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
statusExpr := typeExprForNodePreferred(info, reg, "ExternalStatus", nil, 0, true)
|
|
934
|
+
assertContainsAll(t, statusExpr, "__tsf_runtime_alias__(\"@scope/shared\"", "\"ExternalStatus\"")
|
|
935
|
+
|
|
936
|
+
nullableStatusExpr := typeExprForNodePreferred(info, reg, "ExternalStatus | null", nil, 0, true)
|
|
937
|
+
assertContainsAll(t, nullableStatusExpr, "__tsf_runtime_alias__(\"@scope/shared\"", "\"ExternalStatus\"", "{kind: 5}")
|
|
938
|
+
|
|
939
|
+
statusArrayExpr := typeExprForNodePreferred(info, reg, "ExternalStatus[]", nil, 0, true)
|
|
940
|
+
assertContainsAll(t, statusArrayExpr, "kind: 14", "__tsf_runtime_alias__(\"@scope/shared\"", "\"ExternalStatus\"")
|
|
941
|
+
|
|
942
|
+
workspaceStatusExpr := typeExpr(info, reg, "WorkspaceStatus[]")
|
|
943
|
+
assertContainsAll(t, workspaceStatusExpr, "kind: 14", "__tsf_runtime_alias__(\"@scope/shared\"", "\"WorkspaceStatus\"")
|
|
944
|
+
assertNotContains(t, workspaceStatusExpr, "literal: \"ready\"")
|
|
945
|
+
if canPreferTypiaType(info, reg, "WorkspaceStatus[] & HasDefault") {
|
|
946
|
+
t.Fatal("non-preferred metadata should not route package aliases through Typia just because a TSF marker is present")
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
info.interfaces["CreateRequest"] = []interfaceInfo{{body: "status?: ExternalStatus | null"}}
|
|
950
|
+
createExpr := typeExprForNodePreferred(info, reg, "CreateRequest", nil, 0, true)
|
|
951
|
+
assertContainsAll(t, createExpr, "typeName: \"CreateRequest\"", "name: \"status\"", "__tsf_runtime_alias__(\"@scope/shared\"", "\"ExternalStatus\"")
|
|
952
|
+
|
|
953
|
+
responseExpr := typeExprForNodePreferred(info, reg, "Response", nil, 0, true)
|
|
954
|
+
assertContainsAll(t, responseExpr, "name: \"status\"", "__tsf_runtime_alias__(\"@scope/shared\"")
|
|
955
|
+
|
|
956
|
+
if got, ok := typiaSourcePropertyOverrideExpr(info, reg, "{ status: ExternalStatus }", "status", 0); !ok {
|
|
957
|
+
t.Fatal("Typia property overrides should preserve external imported aliases")
|
|
958
|
+
} else {
|
|
959
|
+
assertContainsAll(t, got, "__tsf_runtime_alias__(\"@scope/shared\"", "\"ExternalStatus\"")
|
|
960
|
+
}
|
|
961
|
+
if typeContainsExternalImportReference(info, reg, "FoundationAlias", map[string]bool{}) {
|
|
962
|
+
t.Fatal("foundation aliases should not trigger external shared-package preservation")
|
|
963
|
+
}
|
|
964
|
+
if !sourceTypeNeedsInternalPropertyMetadata(info, reg, "NullableMySQLCoordinate", &typeContext{seen: map[string]bool{}}, map[string]bool{}) {
|
|
965
|
+
t.Fatal("foundation aliases should preserve runtime alias metadata when Typia expands the source type")
|
|
966
|
+
}
|
|
967
|
+
if got := typeExprForNodePreferred(info, reg, "NullableMySQLCoordinate", nil, 0, true); !strings.Contains(got, runtimeAliasPlaceholderName) {
|
|
968
|
+
t.Fatalf("preferred foundation aliases should use runtime alias metadata: %s", got)
|
|
969
|
+
}
|
|
970
|
+
info.interfaces["LocationResponse"] = []interfaceInfo{{body: "zipGeo: NullableMySQLCoordinate"}}
|
|
971
|
+
if got, ok := typiaSourcePropertyOverrideExpr(info, reg, "LocationResponse", "zipGeo", 0); !ok {
|
|
972
|
+
t.Fatal("Typia property overrides should preserve foundation alias metadata")
|
|
973
|
+
} else {
|
|
974
|
+
assertContainsAll(t, got, "__tsf_runtime_alias__(\"@zyno-io/ts-server-foundation\"", "\"NullableMySQLCoordinate\"")
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
func TestTypeExprUnescapesStringLiteralTypeValues(t *testing.T) {
|
|
979
|
+
info, reg := testTypeInfo()
|
|
980
|
+
|
|
981
|
+
got := typeExpr(info, reg, `string & Pattern<'^\\d+$'>`)
|
|
982
|
+
assertContainsAll(t, got, `validation: [{name: "pattern", args: [{kind: 10, literal: "^\\d+$"}]}]`)
|
|
983
|
+
assertNotContains(t, got, `literal: "^\\\\d+$"`)
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
func TestPatchAliasMetadataSkipsOversizedAliases(t *testing.T) {
|
|
987
|
+
info, reg := testTypeInfo()
|
|
988
|
+
info.aliases["HugeAlias"] = aliasInfo{metadataText: strings.Repeat("x", 1000001), exported: true}
|
|
989
|
+
|
|
990
|
+
got := aliasMetadataExpression(info, reg)
|
|
991
|
+
|
|
992
|
+
assertNotContains(t, got, "HugeAlias")
|
|
993
|
+
assertNotContains(t, got, "__tsfTypeAliases")
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
func TestAliasMetadataOnlyIncludesExportedDeclarations(t *testing.T) {
|
|
997
|
+
info, reg := testTypeInfo()
|
|
998
|
+
info.aliases["PrivateAlias"] = aliasInfo{body: "string"}
|
|
999
|
+
info.aliases["PublicAlias"] = aliasInfo{body: "number", exported: true}
|
|
1000
|
+
info.interfaces["PrivateInterface"] = []interfaceInfo{{body: "value: string"}}
|
|
1001
|
+
info.interfaces["PublicInterface"] = []interfaceInfo{{body: "value: number", exported: true}}
|
|
1002
|
+
|
|
1003
|
+
got := aliasMetadataExpression(info, reg)
|
|
1004
|
+
|
|
1005
|
+
assertContainsAll(t, got, "PublicAlias", "PublicInterface")
|
|
1006
|
+
assertNotContains(t, got, "PrivateAlias")
|
|
1007
|
+
assertNotContains(t, got, "PrivateInterface")
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
func TestGenericInterfaceAliasMetadataRemainsPureJSON(t *testing.T) {
|
|
1011
|
+
info, reg := testTypeInfo()
|
|
1012
|
+
info.interfaces["ManagerApiError"] = []interfaceInfo{{
|
|
1013
|
+
body: "error: string; code?: Code; result?: T",
|
|
1014
|
+
params: []string{"Code", "T"},
|
|
1015
|
+
exported: true,
|
|
1016
|
+
}}
|
|
1017
|
+
|
|
1018
|
+
got := aliasMetadataExpression(info, reg)
|
|
1019
|
+
assertContainsAll(t, got, `typeParameters: ["Code", "T"]`, `{kind: 2, typeName: "Code"}`, `{kind: 2, typeName: "T"}`)
|
|
1020
|
+
assertNotContains(t, got, "classType")
|
|
1021
|
+
template, err := parseExpressionTemplate(got)
|
|
1022
|
+
if err != nil {
|
|
1023
|
+
t.Fatal(err)
|
|
1024
|
+
}
|
|
1025
|
+
if !compactMetadataIsPureJSON(template.parsed) {
|
|
1026
|
+
t.Fatal("generic interface alias metadata should not require the TSF metadata runtime")
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
func TestTypeAliasEmissionDefaultsOnAndCanBeDisabled(t *testing.T) {
|
|
1031
|
+
if !shouldEmitTypeAliases("") {
|
|
1032
|
+
t.Fatal("alias metadata should remain enabled when no plugin config is supplied")
|
|
1033
|
+
}
|
|
1034
|
+
if shouldEmitTypeAliases(`[{"name":"tsf-type-metadata","config":{"emitTypeAliases":false}}]`) {
|
|
1035
|
+
t.Fatal("emitTypeAliases=false should disable alias metadata")
|
|
1036
|
+
}
|
|
1037
|
+
if !shouldEmitTypeAliases(`[{"name":"tsf-type-metadata","config":{"emitTypeAliases":true}}]`) {
|
|
1038
|
+
t.Fatal("emitTypeAliases=true should enable alias metadata")
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
func TestMetadataRuntimeImportDefaultsOnAndCanBeDisabled(t *testing.T) {
|
|
1043
|
+
defaultConfig := readTypeCompilerPluginConfig("")
|
|
1044
|
+
if defaultConfig.EmitMetadataRuntimeImport != nil {
|
|
1045
|
+
t.Fatal("metadata runtime imports should default to enabled")
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
disabled := readTypeCompilerPluginConfig(`[{"name":"tsf-type-metadata","config":{"emitMetadataRuntimeImport":false}}]`)
|
|
1049
|
+
if disabled.EmitMetadataRuntimeImport == nil || *disabled.EmitMetadataRuntimeImport {
|
|
1050
|
+
t.Fatal("emitMetadataRuntimeImport=false should disable metadata runtime imports")
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
enabled := readTypeCompilerPluginConfig(`[{"name":"tsf-type-metadata","config":{"emitMetadataRuntimeImport":true}}]`)
|
|
1054
|
+
if enabled.EmitMetadataRuntimeImport == nil || !*enabled.EmitMetadataRuntimeImport {
|
|
1055
|
+
t.Fatal("emitMetadataRuntimeImport=true should enable metadata runtime imports")
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
func TestUndecoratedMethodEmissionDefaultsOnAndCanBeDisabled(t *testing.T) {
|
|
1060
|
+
defaultConfig := readTypeCompilerPluginConfig("")
|
|
1061
|
+
if defaultConfig.EmitUndecoratedMethods != nil {
|
|
1062
|
+
t.Fatal("undecorated method metadata should default to enabled")
|
|
1063
|
+
}
|
|
1064
|
+
disabled := readTypeCompilerPluginConfig(`[{"name":"tsf-type-metadata","config":{"emitUndecoratedMethods":false}}]`)
|
|
1065
|
+
if disabled.EmitUndecoratedMethods == nil || *disabled.EmitUndecoratedMethods {
|
|
1066
|
+
t.Fatal("emitUndecoratedMethods=false should disable undecorated method metadata")
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
func TestMetadataTypeInternerDeduplicatesExpressionsAndAvoidsSourceNames(t *testing.T) {
|
|
1071
|
+
interner := newMetadataTypeInterner("const __tsf_metadata_type_0 = 'application value'")
|
|
1072
|
+
first := interner.reference("{kind: 6}")
|
|
1073
|
+
repeated := interner.reference("{kind: 6}")
|
|
1074
|
+
second := interner.reference("{kind: 7}")
|
|
1075
|
+
|
|
1076
|
+
if first != repeated {
|
|
1077
|
+
t.Fatalf("repeated metadata expression names differ: %q != %q", first, repeated)
|
|
1078
|
+
}
|
|
1079
|
+
if first == second {
|
|
1080
|
+
t.Fatalf("different metadata expressions share name %q", first)
|
|
1081
|
+
}
|
|
1082
|
+
if first != "___tsf_metadata_type(0)" {
|
|
1083
|
+
t.Fatalf("collision-safe metadata name = %q", first)
|
|
1084
|
+
}
|
|
1085
|
+
if len(interner.expressions) != 2 {
|
|
1086
|
+
t.Fatalf("interned expression count = %d, want 2", len(interner.expressions))
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
func TestTypeExprResolvesReexportedGenericAliases(t *testing.T) {
|
|
1091
|
+
consumer, reg := testTypeInfo()
|
|
1092
|
+
consumer.moduleKey = "test/consumer"
|
|
1093
|
+
consumer.imports["Length"] = importRef{source: "test/index", exportName: "Length", spec: "../src"}
|
|
1094
|
+
index := &fileInfo{
|
|
1095
|
+
moduleKey: "test/index",
|
|
1096
|
+
aliases: map[string]aliasInfo{},
|
|
1097
|
+
imports: map[string]importRef{},
|
|
1098
|
+
reexports: map[string]importRef{
|
|
1099
|
+
"Length": {source: "test/types", exportName: "Length", spec: "./types"},
|
|
1100
|
+
},
|
|
1101
|
+
}
|
|
1102
|
+
types := &fileInfo{
|
|
1103
|
+
moduleKey: "test/types",
|
|
1104
|
+
aliases: map[string]aliasInfo{
|
|
1105
|
+
"Length": {body: "string & MinLength<T> & MaxLength<T> & TypeAnnotation<'tsf:length', T>", params: []string{"T"}},
|
|
1106
|
+
},
|
|
1107
|
+
imports: map[string]importRef{},
|
|
1108
|
+
reexports: map[string]importRef{},
|
|
1109
|
+
}
|
|
1110
|
+
reg.files = map[string]*fileInfo{consumer.moduleKey: consumer, index.moduleKey: index, types.moduleKey: types}
|
|
1111
|
+
reg.byPath = map[string]*fileInfo{consumer.moduleKey: consumer, index.moduleKey: index, types.moduleKey: types}
|
|
1112
|
+
|
|
1113
|
+
got := typeExpr(consumer, reg, "Length<4>")
|
|
1114
|
+
assertContainsAll(t, got,
|
|
1115
|
+
"typeName: \"Length\"",
|
|
1116
|
+
"typeName: \"MinLength\"",
|
|
1117
|
+
"validation: [{name: \"minLength\", args: [{kind: 10, literal: 4}]}]",
|
|
1118
|
+
"typeName: \"MaxLength\"",
|
|
1119
|
+
"validation: [{name: \"maxLength\", args: [{kind: 10, literal: 4}]}]",
|
|
1120
|
+
"annotations: {\"tsf:length\": {kind: 10, literal: 4}}",
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
bounded := typeExpr(consumer, reg, "number & GreaterThan<0> & LessThan<100>")
|
|
1124
|
+
assertContainsAll(t, bounded,
|
|
1125
|
+
"typeName: \"GreaterThan\"",
|
|
1126
|
+
"validation: [{name: \"greaterThan\", args: [{kind: 10, literal: 0}]}]",
|
|
1127
|
+
"typeName: \"LessThan\"",
|
|
1128
|
+
"validation: [{name: \"lessThan\", args: [{kind: 10, literal: 100}]}]",
|
|
1129
|
+
)
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
func assertContainsAll(t *testing.T, got string, wants ...string) {
|
|
1133
|
+
t.Helper()
|
|
1134
|
+
for _, want := range wants {
|
|
1135
|
+
if !strings.Contains(got, want) {
|
|
1136
|
+
t.Fatalf("expression %q does not contain %q", got, want)
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
func assertNotContains(t *testing.T, got string, unwanted string) {
|
|
1142
|
+
t.Helper()
|
|
1143
|
+
if strings.Contains(got, unwanted) {
|
|
1144
|
+
t.Fatalf("expression %q unexpectedly contains %q", got, unwanted)
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
func TestClassMetadataRendersPropertiesMethodsAndConstructor(t *testing.T) {
|
|
1149
|
+
info, reg := testTypeInfo()
|
|
1150
|
+
class := &classInfo{
|
|
1151
|
+
name: "User",
|
|
1152
|
+
properties: []propertyInfo{
|
|
1153
|
+
{name: "id", typeText: "number & PrimaryKey & AutoIncrement"},
|
|
1154
|
+
{name: "email", typeText: "string & Unique<{ name: 'users_email_unique' }>"},
|
|
1155
|
+
},
|
|
1156
|
+
methods: []methodInfo{
|
|
1157
|
+
{name: "rename", description: "Rename the user.", params: []paramInfo{{name: "email", typeText: "string"}}, returnType: "void"},
|
|
1158
|
+
},
|
|
1159
|
+
ctor: []paramInfo{{name: "email", typeText: "string", hasDefault: true}},
|
|
1160
|
+
hasCtor: true,
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
got := classMetadata(info, reg, class, nil)
|
|
1164
|
+
for _, want := range []string{
|
|
1165
|
+
"name: \"User\"",
|
|
1166
|
+
"primaryKey: true",
|
|
1167
|
+
"autoIncrement: true",
|
|
1168
|
+
"unique: {name: \"users_email_unique\"}",
|
|
1169
|
+
"name: \"rename\", parameters: [{name: \"email\", type: {kind: 6}, optional: false, default: false}], returnType: {kind: 3}",
|
|
1170
|
+
"description: \"Rename the user.\"",
|
|
1171
|
+
"constructorParameters: [{name: \"email\", type: {kind: 6}, optional: false, default: true}]",
|
|
1172
|
+
} {
|
|
1173
|
+
if !strings.Contains(got, want) {
|
|
1174
|
+
t.Fatalf("class metadata %q does not contain %q", got, want)
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
func TestClassMetadataCanOmitUndecoratedMethods(t *testing.T) {
|
|
1180
|
+
info, reg := testTypeInfo()
|
|
1181
|
+
class := &classInfo{
|
|
1182
|
+
name: "Controller",
|
|
1183
|
+
decoratedMethodsOnly: true,
|
|
1184
|
+
methods: []methodInfo{
|
|
1185
|
+
{name: "index", returnType: "string", decorated: true},
|
|
1186
|
+
{name: "helper", returnType: "number"},
|
|
1187
|
+
},
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
got := classMetadata(info, reg, class, nil)
|
|
1191
|
+
assertContainsAll(t, got, "name: \"index\"")
|
|
1192
|
+
assertNotContains(t, got, "name: \"helper\"")
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
func TestCleanJsDocDescriptionUsesTheFirstParagraph(t *testing.T) {
|
|
1196
|
+
got := cleanJsDocDescription(`/**
|
|
1197
|
+
* List users using the documented summary.
|
|
1198
|
+
* Continues on the next line.
|
|
1199
|
+
*
|
|
1200
|
+
* This detail is not part of the summary.
|
|
1201
|
+
* @returns users
|
|
1202
|
+
*/`)
|
|
1203
|
+
if got != "List users using the documented summary. Continues on the next line." {
|
|
1204
|
+
t.Fatalf("description = %q", got)
|
|
1205
|
+
}
|
|
1206
|
+
}
|