Repository navigation
Expand file tree
/
Copy pathcode_test.go
More file actions
388 lines (352 loc) · 12.9 KB
/
Copy pathcode_test.go
File metadata and controls
388 lines (352 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
package language
import (
"database/sql/driver"
"encoding/json"
"maps"
"regexp"
"slices"
"testing"
"github.com/domonda/go-types/internal/normtest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCodeNormalized(t *testing.T) {
tests := []struct {
name string
in Code
want Code
}{
// ISO 639-1, case + whitespace.
{"lowercase passes through", "en", EN},
{"uppercase", "EN", EN},
{"mixed case", "De", DE},
{"trim whitespace", " en ", EN},
{"trim + case", " De ", DE},
// ISO 639-2/T and 639-3 (terminologic codes equal the 639-3 form).
{"639-3 eng", "eng", EN},
{"639-3 deu", "deu", DE},
{"639-3 fra", "fra", FR},
{"639-3 zho", "zho", ZH},
{"639-3 spa", "spa", ES},
{"639-3 uppercase", "ENG", EN},
// ISO 639-2/B (bibliographic) variants where they differ from /T.
{"639-2/B ger → de", "ger", DE},
{"639-2/B fre → fr", "fre", FR},
{"639-2/B chi → zh", "chi", ZH},
{"639-2/B dut → nl", "dut", NL},
{"639-2/B alb → sq", "alb", SQ},
// BCP-47: keep only the language subtag.
{"BCP-47 en-US", "en-US", EN},
{"BCP-47 zh-Hant-CN", "zh-Hant-CN", ZH},
{"BCP-47 sr-Latn", "sr-Latn", SR},
{"BCP-47 de-AT", "de-AT", DE},
{"BCP-47 zh-hant-cn", "zh-hant-cn", ZH},
// POSIX-style underscore separator.
{"POSIX en_US", "en_US", EN},
{"POSIX de_AT", "de_AT", DE},
// 639-3 + BCP-47 region.
{"eng-US (3-letter + region)", "eng-US", EN},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.in.Normalized()
require.NoError(t, err, "Normalized(%q)", tt.in)
assert.Equal(t, tt.want, got)
})
}
}
func TestCodeNormalizedErrors(t *testing.T) {
tests := []struct {
name string
in Code
}{
{"empty", ""},
{"only whitespace", " "},
{"single letter", "e"},
{"unknown 2-letter", "xx"},
{"unknown 3-letter (no 639-1 equivalent)", "abq"}, // valid 639-3, but no 639-1
{"gibberish", "qwerty"},
{"language subtag too long", "english"},
{"separator at start", "-en"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.in.Normalized()
require.Error(t, err, "Normalized(%q) should error", tt.in)
// Errors preserve the original input.
assert.Equal(t, tt.in, got, "error path should return input unchanged")
})
}
}
// Valid accepts every spelling that Normalized accepts, like Validate and
// the Valid of every other normalizing type, so Valid and Validate never
// disagree. ValidAndNormalized is the check for the canonical code.
func TestCodeValid(t *testing.T) {
assert.True(t, EN.Valid())
assert.True(t, Code("en").Valid())
assert.True(t, Code("EN").Valid(), "Valid() accepts other cases")
assert.True(t, Code("eng").Valid(), "Valid() accepts 639-3")
assert.True(t, Code("en-US").Valid(), "Valid() accepts BCP-47 tags")
assert.False(t, Code("xx").Valid())
assert.False(t, Null.Valid())
}
func TestCodeValidAndNormalized(t *testing.T) {
assert.True(t, Code("en").ValidAndNormalized())
assert.False(t, Code("EN").ValidAndNormalized())
assert.False(t, Code("eng").ValidAndNormalized())
assert.False(t, Code("en-US").ValidAndNormalized())
}
func TestCodeLanguageName(t *testing.T) {
assert.Equal(t, "English", EN.LanguageName())
assert.Equal(t, "German", DE.LanguageName())
assert.Equal(t, "", Code("xx").LanguageName())
assert.Equal(t, "", Null.LanguageName())
// Like country.Code.EnglishName, the name is
// looked up for the normalized code, so every input that Normalized
// accepts has a name (e.g. language codes read from user input or HTTP headers).
assert.Equal(t, "English", Code("EN").LanguageName())
assert.Equal(t, "English", Code(" en ").LanguageName())
assert.Equal(t, "English", Code("eng").LanguageName())
assert.Equal(t, "English", Code("en-US").LanguageName())
assert.Equal(t, "German", Code("ger").LanguageName())
}
func TestParseCode(t *testing.T) {
tests := []struct {
name string
in string
want Code
}{
// 1. Short path: already a canonical ISO 639-1 code.
{"canonical 639-1", "en", EN},
{"canonical 639-1 de", "de", DE},
// 2. Codes and tags, resolved via Normalized.
{"uppercase", "EN", EN},
{"whitespace", " de ", DE},
{"639-3 code", "eng", EN},
{"639-2/B bibliographic", "ger", DE},
{"BCP-47 tag", "en-US", EN},
{"POSIX locale", "zh_Hant", ZH},
// 3. English language name.
{"name", "German", DE},
{"name lowercase", "english", EN},
{"name with whitespace", " French ", FR},
{"name primary from synonym list", "Catalan", CA},
{"name synonym from synonym list", "Valencian", CA},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseCode(tt.in)
require.NoError(t, err, "ParseCode(%q)", tt.in)
assert.Equal(t, tt.want, got)
})
}
}
func TestParseCodeErrors(t *testing.T) {
tests := []struct {
name string
in string
}{
{"empty", ""},
{"whitespace only", " "},
{"gibberish", "qwerty"},
{"unknown 2-letter", "xx"},
{"639-3 without 639-1 equivalent", "abq"},
{"language name without 639-1 code", "Klingon"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseCode(tt.in)
require.Error(t, err, "ParseCode(%q) should error", tt.in)
// Errors preserve the original input.
assert.Equal(t, Code(tt.in), got, "error path should return input unchanged")
})
}
}
// TestCode_EnumsMatchCodeNames ties the two lists of language codes together:
// Enums() is generated from the constants in code.go, but Valid() looks up
// codeNames in constants.go. A code added to only one of them would make
// Enums() list a code that Valid() rejects, or miss one that it accepts.
func TestCode_EnumsMatchCodeNames(t *testing.T) {
assert.ElementsMatch(t, slices.Collect(maps.Keys(codeNames)), Code("").Enums())
}
// TestCode_ValidateStringAndNullSentinel covers the three parts of Code's
// public surface that the //#enum migration created or had to protect.
//
// Valid and Validate carry a #custom marker because they normalize first,
// like country.Code and money.Currency, so input that types.Validate and
// DeepValidate receive from users ("EN", "eng", "en-US") is not rejected,
// and the error of Validate names the offending input. Reading and Value
// only trim, so those spellings stay until Normalized. The strict
// exact-match methods that go-enum would generate instead reject all of
// them. String is the one go-enum generates: it returns the code as stored,
// so fmt shows what a Code holds. Null is marked //#exclude so that go-enum
// keeps it out of the enum value lists while it stays a typed Code constant
// with working methods.
//
// Value: protects=language.Code's #custom Valid and Validate accept what
// Normalized accepts and Validate names the input, String returns the
// stored code, and Null stays a typed sentinel outside Enums and
// EnumStrings;
// fails_when=go-enum regenerates Valid or Validate as the strict lookup,
// String normalizes again, or Null loses its //#exclude marker;
// why_new=Validate and String have no test at all and
// TestCode_EnumsMatchCodeNames never looks at EnumStrings or Null;
// seam=none
func TestCode_ValidateStringAndNullSentinel(t *testing.T) {
t.Run("Validate accepts every enumerated code", func(t *testing.T) {
for _, c := range Code("").Enums() {
require.NoError(t, c.Validate(), "Code(%q).Validate()", string(c))
}
})
t.Run("Validate accepts what Normalized accepts", func(t *testing.T) {
// Value and MarshalJSON write them normalized,
// so validation can pass them.
for _, in := range []Code{"EN", " de ", "eng", "ger", "en-US", "zh_Hant"} {
require.True(t, in.Valid(), "Code(%q).Valid()", string(in))
require.NoError(t, in.Validate(), "Code(%q).Validate()", string(in))
}
})
t.Run("Validate rejects what Normalized rejects", func(t *testing.T) {
for _, in := range []Code{"", "xx", "zzz", "English"} {
err := in.Validate()
require.Error(t, err, "Code(%q).Validate() should error", string(in))
// The error has to name the offending value to be diagnosable.
assert.ErrorContains(t, err, string(in))
}
})
t.Run("String returns the stored code", func(t *testing.T) {
// fmt must show what a Code holds, so a non-canonical
// value can't pass for the canonical one in a log
for _, c := range []Code{"en", "EN", "eng", "en-US", "xx"} {
assert.Equal(t, string(c), c.String())
}
})
t.Run("Null sentinel", func(t *testing.T) {
// Compile-time: Null stays a typed Code constant, not an untyped
// string, so it keeps its methods and can be compared to a Code.
var _ Code = Null
assert.False(t, Null.Valid(), "language.Null.Valid() must be false")
assert.Error(t, Null.Validate())
assert.NotContains(t, Code("").Enums(), Null, "Null is not a valid language code")
assert.NotContains(t, Code("").EnumStrings(), string(Null))
})
t.Run("EnumStrings matches Enums", func(t *testing.T) {
// Both lists are generated independently from the same constants, so
// nothing but this assertion keeps them from drifting apart.
enums := Code("").Enums()
want := make([]string, len(enums))
for i, c := range enums {
want[i] = string(c)
}
assert.Equal(t, want, Code("").EnumStrings())
})
}
// TestCode_ScanValue protects the SQL NULL mapping documented on Code:
// the empty Code (Null) is written as SQL NULL, and SQL NULL is scanned
// back as Null, so a nullable language column round-trips without turning
// NULL into an empty string or vice versa.
func TestCode_ScanValue(t *testing.T) {
t.Run("Scan", func(t *testing.T) {
tests := []struct {
name string
value any
want Code
}{
{name: "string", value: "de", want: DE},
{name: "bytes", value: []byte("fr"), want: FR},
{name: "NULL", value: nil, want: Null},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := EN // non-zero start value to detect that Scan overwrites it
require.NoError(t, c.Scan(tt.value))
assert.Equal(t, tt.want, c)
})
}
})
t.Run("Scan unsupported type", func(t *testing.T) {
c := EN
err := c.Scan(42)
require.Error(t, err)
assert.Equal(t, EN, c, "failed Scan must not modify the Code")
})
t.Run("Value", func(t *testing.T) {
v, err := Null.Value()
require.NoError(t, err)
assert.Nil(t, v, "Null must be written as SQL NULL, not as empty string")
v, err = DE.Value()
require.NoError(t, err)
assert.Equal(t, driver.Value("de"), v)
})
t.Run("round-trip", func(t *testing.T) {
for _, c := range []Code{Null, EN, DE, ZH} {
v, err := c.Value()
require.NoError(t, err)
var scanned Code
require.NoError(t, scanned.Scan(v))
assert.Equal(t, c, scanned)
}
})
}
// Value and MarshalJSON write a Code normalized, because Validate accepts
// every spelling that Normalized accepts: without normalization "EN" and
// "en-US" would reach SQL and JSON as given and fail the JSON schema
// pattern ^[a-z]{2}$. Value and JSON only trim, so they can write "EN"
// and an invalid code, and a response that holds one still renders.
func TestCode_ValueAndMarshalJSONNormalize(t *testing.T) {
tests := []struct {
c Code
want string
}{
{c: DE, want: "de"},
{c: "EN", want: "EN"},
{c: " eng ", want: "eng"},
{c: "en-US", want: "en-US"},
{c: "ger", want: "ger"},
}
for _, tt := range tests {
v, err := tt.c.Value()
require.NoError(t, err)
assert.Equal(t, driver.Value(tt.want), v, "Code(%q).Value()", string(tt.c))
j, err := json.Marshal(tt.c)
require.NoError(t, err)
assert.Equal(t, `"`+tt.want+`"`, string(j), "json.Marshal(Code(%q))", string(tt.c))
}
v, err := Code(" xx ").Value()
require.NoError(t, err)
assert.Equal(t, driver.Value("xx"), v, "Value() of an invalid code is trimmed")
j, err := json.Marshal(Code(" xx "))
require.NoError(t, err)
assert.Equal(t, `"xx"`, string(j), "json.Marshal of an invalid code")
j, err = json.Marshal(Null)
require.NoError(t, err)
assert.Equal(t, `""`, string(j), "Null is no JSON null")
}
// TestCode_JSONSchema protects that the published JSON schema accepts every
// valid language code and rejects non-normalized forms, so schema based
// validation agrees with Code.ValidAndNormalized.
func TestCode_JSONSchema(t *testing.T) {
schema := Code("").JSONSchema()
require.NotNil(t, schema)
assert.Equal(t, "string", schema.Type)
pattern := regexp.MustCompile(schema.Pattern)
for _, c := range Code("").Enums() {
assert.True(t, pattern.MatchString(string(c)), "schema pattern must accept %q", c)
}
for _, s := range []string{"", "EN", "eng", "en-US", "e"} {
assert.False(t, pattern.MatchString(s), "schema pattern must reject %q", s)
}
}
// TestCode_ReadingAndWriting checks the reading and writing contract shared
// by the normalizing types: every reading path stores a valid code
// trimmed, case kept, an invalid code written that way,
// and fmt prints the stored code.
func TestCode_ReadingAndWriting(t *testing.T) {
normtest.Run(t, normtest.Spec[Code]{
Canonical: EN,
Variants: []Code{"EN", " en ", "eng", "en-US", "en_GB"},
Invalid: []Code{"xx", "English"},
StoredString: true,
Prettify: normtest.Trim[Code],
})
}