@0xd33p/vietnam-divisions 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +80 -0
  2. package/dist/__tests__/vn.test.d.ts +2 -0
  3. package/dist/__tests__/vn.test.d.ts.map +1 -0
  4. package/dist/__tests__/vn.test.js +1107 -0
  5. package/dist/__tests__/vn.test.js.map +1 -0
  6. package/dist/data/legacy/commune.json +11077 -0
  7. package/dist/data/legacy/district.json +730 -0
  8. package/dist/data/legacy/province.json +65 -0
  9. package/dist/data/v3/commune.json +16607 -0
  10. package/dist/data/v3/province.json +240 -0
  11. package/dist/data/v3/province_merges.json +256 -0
  12. package/dist/data/v3/ward_mappings.json +98795 -0
  13. package/dist/index.d.ts +62 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +332 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/internal/address.d.ts +37 -0
  18. package/dist/internal/address.d.ts.map +1 -0
  19. package/dist/internal/address.js +164 -0
  20. package/dist/internal/address.js.map +1 -0
  21. package/dist/internal/helpers.d.ts +24 -0
  22. package/dist/internal/helpers.d.ts.map +1 -0
  23. package/dist/internal/helpers.js +55 -0
  24. package/dist/internal/helpers.js.map +1 -0
  25. package/dist/internal/legacy/index.d.ts +21 -0
  26. package/dist/internal/legacy/index.d.ts.map +1 -0
  27. package/dist/internal/legacy/index.js +60 -0
  28. package/dist/internal/legacy/index.js.map +1 -0
  29. package/dist/internal/legacy/types.d.ts +18 -0
  30. package/dist/internal/legacy/types.d.ts.map +1 -0
  31. package/dist/internal/legacy/types.js +2 -0
  32. package/dist/internal/legacy/types.js.map +1 -0
  33. package/dist/internal/v3/index.d.ts +21 -0
  34. package/dist/internal/v3/index.d.ts.map +1 -0
  35. package/dist/internal/v3/index.js +55 -0
  36. package/dist/internal/v3/index.js.map +1 -0
  37. package/dist/internal/v3/types.d.ts +33 -0
  38. package/dist/internal/v3/types.d.ts.map +1 -0
  39. package/dist/internal/v3/types.js +2 -0
  40. package/dist/internal/v3/types.js.map +1 -0
  41. package/dist/types.d.ts +37 -0
  42. package/dist/types.d.ts.map +1 -0
  43. package/dist/types.js +2 -0
  44. package/dist/types.js.map +1 -0
  45. package/package.json +32 -0
@@ -0,0 +1,1107 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { vn } from '../index.js';
3
+ import { provinceMerges, wardMappings } from '../internal/v3/index.js';
4
+ // ─── Helper: type guards ──────────────────────────────
5
+ function isSuccess(r) {
6
+ return r.data !== null && !('error' in r);
7
+ }
8
+ function isError(r) {
9
+ return r.data === null && 'error' in r;
10
+ }
11
+ // ─── vn.divisions() ────────────────────────────────────
12
+ describe('vn.divisions()', () => {
13
+ it('returns 34 provinces (v3 system) when called without args', () => {
14
+ const r = vn.divisions();
15
+ expect(isSuccess(r)).toBe(true);
16
+ if (isSuccess(r)) {
17
+ expect(r.data).toHaveLength(34);
18
+ }
19
+ });
20
+ it('each province has id and name string', () => {
21
+ const r = vn.divisions();
22
+ if (isSuccess(r)) {
23
+ for (const p of r.data) {
24
+ expect(p).toHaveProperty('id');
25
+ expect(p).toHaveProperty('name');
26
+ expect(typeof p.id).toBe('string');
27
+ expect(typeof p.name).toBe('string');
28
+ expect(p.id.length).toBeGreaterThan(0);
29
+ expect(p.name.length).toBeGreaterThan(0);
30
+ }
31
+ }
32
+ });
33
+ it('first province is Hà Nội (id: 01)', () => {
34
+ const r = vn.divisions();
35
+ if (isSuccess(r)) {
36
+ expect(r.data[0]).toEqual({ id: '01', name: 'Thành phố Hà Nội' });
37
+ }
38
+ });
39
+ it('last province is Cà Mau (id: 96)', () => {
40
+ const r = vn.divisions();
41
+ if (isSuccess(r)) {
42
+ const last = r.data[r.data.length - 1];
43
+ expect(last).toEqual({ id: '96', name: 'Cà Mau' });
44
+ }
45
+ });
46
+ it('includes HCM (id: 79)', () => {
47
+ const r = vn.divisions();
48
+ if (isSuccess(r)) {
49
+ expect(r.data).toContainEqual({
50
+ id: '79',
51
+ name: 'Thành phố Hồ Chí Minh',
52
+ });
53
+ }
54
+ });
55
+ it('includes Đà Nẵng (id: 48)', () => {
56
+ const r = vn.divisions();
57
+ if (isSuccess(r)) {
58
+ expect(r.data).toContainEqual({ id: '48', name: 'Thành phố Đà Nẵng' });
59
+ }
60
+ });
61
+ });
62
+ // ─── vn.divisions(parentId) ────────────────────────────
63
+ describe('vn.divisions(parentId)', () => {
64
+ it('returns communes for a valid provinceId', () => {
65
+ const r = vn.divisions('79');
66
+ expect(isSuccess(r)).toBe(true);
67
+ });
68
+ it('HCM (79) has ~168 communes', () => {
69
+ const r = vn.divisions('79');
70
+ if (isSuccess(r)) {
71
+ expect(r.data.length).toBeGreaterThan(100);
72
+ expect(r.data.length).toBeLessThan(300);
73
+ }
74
+ });
75
+ it('each commune has id and name', () => {
76
+ const r = vn.divisions('79');
77
+ if (isSuccess(r)) {
78
+ for (const c of r.data) {
79
+ expect(c).toHaveProperty('id');
80
+ expect(c).toHaveProperty('name');
81
+ expect(typeof c.id).toBe('string');
82
+ expect(typeof c.name).toBe('string');
83
+ expect(c.id.length).toBeGreaterThan(0);
84
+ expect(c.name.length).toBeGreaterThan(0);
85
+ }
86
+ }
87
+ });
88
+ it('Hà Nội (01) communes start with "Phường Ba Đình"', () => {
89
+ const r = vn.divisions('01');
90
+ if (isSuccess(r)) {
91
+ expect(r.data[0].name).toContain('Ba Đình');
92
+ }
93
+ });
94
+ it('returns error for invalid provinceId', () => {
95
+ const r = vn.divisions('invalid_id');
96
+ expect(isError(r)).toBe(true);
97
+ if (isError(r)) {
98
+ expect(r.error).toBe('empty_commune_list');
99
+ }
100
+ });
101
+ it('returns error for empty string', () => {
102
+ const r = vn.divisions('');
103
+ expect(isError(r)).toBe(true);
104
+ if (isError(r)) {
105
+ expect(r.error).toBe('invalid_province_id');
106
+ }
107
+ });
108
+ });
109
+ // ─── vn.lookup() ──────────────────────────────────────
110
+ describe('vn.lookup()', () => {
111
+ it('finds province by numeric ID "79" → HCM', () => {
112
+ const r = vn.lookup('79');
113
+ expect(isSuccess(r)).toBe(true);
114
+ if (isSuccess(r)) {
115
+ expect(r.data).toEqual({
116
+ id: '79',
117
+ name: 'Thành phố Hồ Chí Minh',
118
+ kind: 'province',
119
+ });
120
+ }
121
+ });
122
+ it('finds province by 3-letter code "HCM"', () => {
123
+ const r = vn.lookup('HCM');
124
+ expect(isSuccess(r)).toBe(true);
125
+ if (isSuccess(r)) {
126
+ expect(r.data).toEqual({
127
+ id: '79',
128
+ name: 'Thành phố Hồ Chí Minh',
129
+ kind: 'province',
130
+ });
131
+ }
132
+ });
133
+ it('finds province by lowercase code "hcm"', () => {
134
+ const r = vn.lookup('hcm');
135
+ expect(isSuccess(r)).toBe(true);
136
+ if (isSuccess(r)) {
137
+ expect(r.data.id).toBe('79');
138
+ }
139
+ });
140
+ it('finds Hà Nội by ID "01"', () => {
141
+ const r = vn.lookup('01');
142
+ expect(isSuccess(r)).toBe(true);
143
+ if (isSuccess(r)) {
144
+ expect(r.data).toEqual({
145
+ id: '01',
146
+ name: 'Thành phố Hà Nội',
147
+ kind: 'province',
148
+ });
149
+ }
150
+ });
151
+ it('finds Hà Nội by code "HNI"', () => {
152
+ const r = vn.lookup('HNI');
153
+ expect(isSuccess(r)).toBe(true);
154
+ if (isSuccess(r)) {
155
+ expect(r.data.id).toBe('01');
156
+ }
157
+ });
158
+ it('finds HCM by full name', () => {
159
+ const r = vn.lookup('Thành phố Hồ Chí Minh');
160
+ expect(isSuccess(r)).toBe(true);
161
+ if (isSuccess(r)) {
162
+ expect(r.data).toEqual({
163
+ id: '79',
164
+ name: 'Thành phố Hồ Chí Minh',
165
+ kind: 'province',
166
+ });
167
+ }
168
+ });
169
+ it('returns error for invalid query', () => {
170
+ const r = vn.lookup('NONEXISTENT');
171
+ expect(isError(r)).toBe(true);
172
+ if (isError(r)) {
173
+ expect(r.error).toBe('province_not_found');
174
+ }
175
+ });
176
+ it('returns error for empty string', () => {
177
+ const r = vn.lookup('');
178
+ expect(isError(r)).toBe(true);
179
+ if (isError(r)) {
180
+ expect(r.error).toBe('invalid_query');
181
+ }
182
+ });
183
+ });
184
+ // ─── vn.legacy.divisions() ─────────────────────────────
185
+ describe('vn.legacy.divisions()', () => {
186
+ it('returns 63 provinces when called without args', () => {
187
+ const r = vn.legacy.divisions();
188
+ expect(isSuccess(r)).toBe(true);
189
+ if (isSuccess(r)) {
190
+ expect(r.data).toHaveLength(63);
191
+ }
192
+ });
193
+ it('each has id and name string', () => {
194
+ const r = vn.legacy.divisions();
195
+ if (isSuccess(r)) {
196
+ for (const p of r.data) {
197
+ expect(p).toHaveProperty('id');
198
+ expect(p).toHaveProperty('name');
199
+ expect(typeof p.id).toBe('string');
200
+ expect(typeof p.name).toBe('string');
201
+ }
202
+ }
203
+ });
204
+ it('includes old provinces like "Tỉnh Hà Giang"', () => {
205
+ const r = vn.legacy.divisions();
206
+ if (isSuccess(r)) {
207
+ expect(r.data).toContainEqual({ id: '02', name: 'Tỉnh Hà Giang' });
208
+ }
209
+ });
210
+ });
211
+ // ─── vn.legacy.divisions(parentId) ─────────────────────
212
+ describe('vn.legacy.divisions(parentId)', () => {
213
+ it('Hà Nội (01) has 30 districts', () => {
214
+ const r = vn.legacy.divisions('01');
215
+ expect(isSuccess(r)).toBe(true);
216
+ if (isSuccess(r)) {
217
+ expect(r.data).toHaveLength(30);
218
+ }
219
+ });
220
+ it('each district has id and name', () => {
221
+ const r = vn.legacy.divisions('01');
222
+ if (isSuccess(r)) {
223
+ for (const d of r.data) {
224
+ expect(d).toHaveProperty('id');
225
+ expect(d).toHaveProperty('name');
226
+ expect(typeof d.id).toBe('string');
227
+ expect(typeof d.name).toBe('string');
228
+ }
229
+ }
230
+ });
231
+ it('first district is "Quận Ba Đình"', () => {
232
+ const r = vn.legacy.divisions('01');
233
+ if (isSuccess(r)) {
234
+ expect(r.data[0]).toEqual({ id: '001', name: 'Quận Ba Đình' });
235
+ }
236
+ });
237
+ it('returns error for invalid provinceId', () => {
238
+ const r = vn.legacy.divisions('invalid');
239
+ expect(isError(r)).toBe(true);
240
+ if (isError(r)) {
241
+ expect(r.error).toBe('empty_district_list');
242
+ }
243
+ });
244
+ it('returns error for empty string', () => {
245
+ const r = vn.legacy.divisions('');
246
+ expect(isError(r)).toBe(true);
247
+ if (isError(r)) {
248
+ expect(r.error).toBe('invalid_province_id');
249
+ }
250
+ });
251
+ });
252
+ // ─── Data integrity ───────────────────────────────────
253
+ describe('data integrity', () => {
254
+ describe('v3 provinces', () => {
255
+ it('all province IDs are unique', () => {
256
+ const r = vn.divisions();
257
+ if (isSuccess(r)) {
258
+ const ids = r.data.map((p) => p.id);
259
+ expect(new Set(ids).size).toBe(ids.length);
260
+ }
261
+ });
262
+ });
263
+ describe('v3 communes', () => {
264
+ it('all commune IDs are unique', () => {
265
+ const r = vn.divisions();
266
+ if (isSuccess(r)) {
267
+ const allIds = [];
268
+ for (const p of r.data) {
269
+ const divisions = vn.divisions(p.id);
270
+ if (isSuccess(divisions)) {
271
+ allIds.push(...divisions.data.map((c) => c.id));
272
+ }
273
+ }
274
+ expect(new Set(allIds).size).toBe(allIds.length);
275
+ expect(allIds.length).toBe(3321);
276
+ }
277
+ });
278
+ it('every commune belongs to a valid province', () => {
279
+ const r = vn.divisions();
280
+ if (isSuccess(r)) {
281
+ for (const p of r.data) {
282
+ const divisions = vn.divisions(p.id);
283
+ if (isSuccess(divisions)) {
284
+ expect(divisions.data.length).toBeGreaterThan(0);
285
+ }
286
+ }
287
+ }
288
+ });
289
+ });
290
+ describe('legacy provinces', () => {
291
+ it('all legacy province IDs are unique', () => {
292
+ const r = vn.legacy.divisions();
293
+ if (isSuccess(r)) {
294
+ const ids = r.data.map((p) => p.id);
295
+ expect(new Set(ids).size).toBe(ids.length);
296
+ }
297
+ });
298
+ });
299
+ describe('legacy districts', () => {
300
+ it('all district IDs within a province are unique', () => {
301
+ const rp = vn.legacy.divisions();
302
+ if (isSuccess(rp)) {
303
+ for (const p of rp.data) {
304
+ const rd = vn.legacy.divisions(p.id);
305
+ if (isSuccess(rd)) {
306
+ const ids = rd.data.map((d) => d.id);
307
+ expect(new Set(ids).size).toBe(ids.length);
308
+ }
309
+ }
310
+ }
311
+ });
312
+ it('every district belongs to a valid province', () => {
313
+ const rp = vn.legacy.divisions();
314
+ if (isSuccess(rp)) {
315
+ const provinceIds = new Set(rp.data.map((p) => p.id));
316
+ for (const p of rp.data) {
317
+ const rd = vn.legacy.divisions(p.id);
318
+ if (isSuccess(rd)) {
319
+ for (const d of rd.data) {
320
+ expect(d.id.length).toBeGreaterThan(0);
321
+ }
322
+ }
323
+ }
324
+ }
325
+ });
326
+ });
327
+ describe('legacy communes', () => {
328
+ it('every district lookup by 3-char ID returns district kind', () => {
329
+ const rp = vn.legacy.divisions();
330
+ if (isSuccess(rp)) {
331
+ for (const p of rp.data) {
332
+ const rd = vn.legacy.divisions(p.id);
333
+ if (isSuccess(rd) && rd.data.length > 0) {
334
+ const firstDistrict = rd.data[0];
335
+ const lookup = vn.legacy.lookup(firstDistrict.id);
336
+ expect(isSuccess(lookup)).toBe(true);
337
+ if (isSuccess(lookup)) {
338
+ expect(lookup.data.kind).toBe('district');
339
+ }
340
+ }
341
+ }
342
+ }
343
+ });
344
+ });
345
+ });
346
+ // ─── Discriminated union shape ─────────────────────────
347
+ describe('GeoResult / GeoErrorResult type shape', () => {
348
+ it('success result has data but no error property', () => {
349
+ const r = vn.divisions();
350
+ if (isSuccess(r)) {
351
+ expect(r).toHaveProperty('data');
352
+ expect(r).not.toHaveProperty('error');
353
+ }
354
+ });
355
+ it('error result has data=null and string error', () => {
356
+ const r = vn.divisions('');
357
+ if (isError(r)) {
358
+ expect(r.data).toBeNull();
359
+ expect(typeof r.error).toBe('string');
360
+ }
361
+ });
362
+ });
363
+ // ─── Block A: vn.divisions() — edge cases ───────────────
364
+ describe('vn.divisions() — edge cases and boundary inputs', () => {
365
+ it('REGRESSION: null parentId → throws TypeError (TypeScript prevents at compile time)', () => {
366
+ expect(() => vn.divisions(null)).toThrow(TypeError);
367
+ });
368
+ it('EDGE: undefined parentId → treated as no-arg, returns 34 provinces', () => {
369
+ const r = vn.divisions(undefined);
370
+ expect(isSuccess(r)).toBe(true);
371
+ if (isSuccess(r))
372
+ expect(r.data).toHaveLength(34);
373
+ });
374
+ it('EDGE: whitespace " " parentId → empty_commune_list (not trimmed)', () => {
375
+ const r = vn.divisions(' ');
376
+ expect(isError(r)).toBe(true);
377
+ if (isError(r))
378
+ expect(r.error).toBe('empty_commune_list');
379
+ });
380
+ it('EDGE: padded " 79 " parentId → empty_commune_list (NOT trimmed to "79")', () => {
381
+ const r = vn.divisions(' 79 ');
382
+ expect(isError(r)).toBe(true);
383
+ if (isError(r))
384
+ expect(r.error).toBe('empty_commune_list');
385
+ });
386
+ it('EDGE: "0" parentId → empty_commune_list', () => {
387
+ const r = vn.divisions('0');
388
+ expect(isError(r)).toBe(true);
389
+ if (isError(r))
390
+ expect(r.error).toBe('empty_commune_list');
391
+ });
392
+ it('EDGE: 100-char parentId → empty_commune_list', () => {
393
+ const r = vn.divisions('a'.repeat(100));
394
+ expect(isError(r)).toBe(true);
395
+ if (isError(r))
396
+ expect(r.error).toBe('empty_commune_list');
397
+ });
398
+ it('EDGE: special chars "!@#$%" parentId → empty_commune_list', () => {
399
+ const r = vn.divisions('!@#$%');
400
+ expect(isError(r)).toBe(true);
401
+ if (isError(r))
402
+ expect(r.error).toBe('empty_commune_list');
403
+ });
404
+ it('DATA_INTEGRITY: all 34 provinces → each has ≥1 commune', () => {
405
+ const r = vn.divisions();
406
+ if (isSuccess(r)) {
407
+ for (const p of r.data) {
408
+ const communes = vn.divisions(p.id);
409
+ expect(isSuccess(communes)).toBe(true);
410
+ if (isSuccess(communes)) {
411
+ expect(communes.data.length).toBeGreaterThan(0);
412
+ }
413
+ }
414
+ }
415
+ });
416
+ });
417
+ // ─── Block B: vn.lookup() — complete branch coverage ────
418
+ describe('vn.lookup() — complete branch coverage', () => {
419
+ // === Happy paths (already in existing tests, adding more) ===
420
+ // === Auto-resolve: old province ID merge ===
421
+ it('MAPPING: old province ID "74" (Bình Dương) → resolves to HCM (79)', () => {
422
+ const r = vn.lookup('74');
423
+ expect(isSuccess(r)).toBe(true);
424
+ if (isSuccess(r)) {
425
+ expect(r.data.id).toBe('79');
426
+ expect(r.data.name).toBe('Thành phố Hồ Chí Minh');
427
+ expect(r.data.kind).toBe('province');
428
+ }
429
+ });
430
+ it('MAPPING: old province ID "77" (Bà Rịa-Vũng Tàu) → resolves to HCM (79)', () => {
431
+ const r = vn.lookup('77');
432
+ expect(isSuccess(r)).toBe(true);
433
+ if (isSuccess(r)) {
434
+ expect(r.data.id).toBe('79');
435
+ expect(r.data.name).toBe('Thành phố Hồ Chí Minh');
436
+ expect(r.data.kind).toBe('province');
437
+ }
438
+ });
439
+ it('MAPPING: old province ID "58" (Ninh Thuận) → resolves to Khánh Hòa (56)', () => {
440
+ const r = vn.lookup('58');
441
+ expect(isSuccess(r)).toBe(true);
442
+ if (isSuccess(r)) {
443
+ expect(r.data.id).toBe('56');
444
+ expect(r.data.name).toBe('Khánh Hòa');
445
+ expect(r.data.kind).toBe('province');
446
+ }
447
+ });
448
+ // === Auto-resolve: old commune ID mapping ===
449
+ it('MAPPING: old ward code "26881" → resolves to new ward "26882"', () => {
450
+ const r = vn.lookup('26881');
451
+ expect(isSuccess(r)).toBe(true);
452
+ if (isSuccess(r)) {
453
+ expect(r.data.id).toBe('26882');
454
+ expect(r.data.kind).toBe('ward');
455
+ }
456
+ });
457
+ // === Direct commune ID lookup ===
458
+ it('EXACT MATCH: commune ID "00004" → Phường Ba Đình (ward)', () => {
459
+ const r = vn.lookup('00004');
460
+ expect(isSuccess(r)).toBe(true);
461
+ if (isSuccess(r)) {
462
+ expect(r.data).toEqual({
463
+ id: '00004',
464
+ name: 'Phường Ba Đình',
465
+ kind: 'ward',
466
+ });
467
+ }
468
+ });
469
+ it('EXACT MATCH: commune ID "00008" → Phường Ngọc Hà (ward)', () => {
470
+ const r = vn.lookup('00008');
471
+ expect(isSuccess(r)).toBe(true);
472
+ if (isSuccess(r)) {
473
+ expect(r.data).toEqual({
474
+ id: '00008',
475
+ name: 'Phường Ngọc Hà',
476
+ kind: 'ward',
477
+ });
478
+ }
479
+ });
480
+ // === Edge inputs ===
481
+ it('EDGE: null query → invalid_query', () => {
482
+ const r = vn.lookup(null);
483
+ expect(isError(r)).toBe(true);
484
+ if (isError(r))
485
+ expect(r.error).toBe('invalid_query');
486
+ });
487
+ it('EDGE: undefined query → invalid_query', () => {
488
+ const r = vn.lookup(undefined);
489
+ expect(isError(r)).toBe(true);
490
+ if (isError(r))
491
+ expect(r.error).toBe('invalid_query');
492
+ });
493
+ it('KNOWN_DATA_QUIRK: whitespace " " trims to "" and matches empty oldWardCode mapping → unexpected ward', () => {
494
+ // NOTE: There is a ward mapping with oldWardCode="" in the data,
495
+ // so lookup(" ") accidentally resolves to that ward.
496
+ const r = vn.lookup(' ');
497
+ expect(isSuccess(r)).toBe(true);
498
+ if (isSuccess(r)) {
499
+ expect(r.data.kind).toBe('ward');
500
+ expect(r.data.id).toBe('11948');
501
+ }
502
+ });
503
+ it('EDGE: padded valid code " HCM " → trims to "HCM" → HCM', () => {
504
+ const r = vn.lookup(' HCM ');
505
+ expect(isSuccess(r)).toBe(true);
506
+ if (isSuccess(r)) {
507
+ expect(r.data.id).toBe('79');
508
+ expect(r.data.name).toBe('Thành phố Hồ Chí Minh');
509
+ }
510
+ });
511
+ it('EDGE: padded valid ID " 79 " → trims to "79" → HCM', () => {
512
+ const r = vn.lookup(' 79 ');
513
+ expect(isSuccess(r)).toBe(true);
514
+ if (isSuccess(r)) {
515
+ expect(r.data.id).toBe('79');
516
+ expect(r.data.name).toBe('Thành phố Hồ Chí Minh');
517
+ }
518
+ });
519
+ // === Non-standard lengths ===
520
+ it('EDGE: 1-char "a" → province_not_found', () => {
521
+ const r = vn.lookup('a');
522
+ expect(isError(r)).toBe(true);
523
+ if (isError(r))
524
+ expect(r.error).toBe('province_not_found');
525
+ });
526
+ it('EDGE: 2-char non-numeric "ab" → province_not_found', () => {
527
+ const r = vn.lookup('ab');
528
+ expect(isError(r)).toBe(true);
529
+ if (isError(r))
530
+ expect(r.error).toBe('province_not_found');
531
+ });
532
+ it('EDGE: 3-char nonexistent code "ABC" → province_not_found', () => {
533
+ const r = vn.lookup('ABC');
534
+ expect(isError(r)).toBe(true);
535
+ if (isError(r))
536
+ expect(r.error).toBe('province_not_found');
537
+ });
538
+ it('EDGE: 4-char "abcd" → falls through to name search → province_not_found', () => {
539
+ const r = vn.lookup('abcd');
540
+ expect(isError(r)).toBe(true);
541
+ if (isError(r))
542
+ expect(r.error).toBe('province_not_found');
543
+ });
544
+ it('EDGE: 6-char "abcdef" → falls through to name search → province_not_found', () => {
545
+ const r = vn.lookup('abcdef');
546
+ expect(isError(r)).toBe(true);
547
+ if (isError(r))
548
+ expect(r.error).toBe('province_not_found');
549
+ });
550
+ it('EDGE: very long string (100 chars) → province_not_found', () => {
551
+ const r = vn.lookup('x'.repeat(100));
552
+ expect(isError(r)).toBe(true);
553
+ if (isError(r))
554
+ expect(r.error).toBe('province_not_found');
555
+ });
556
+ // === Unicode/case ===
557
+ it('UNICODE: "hồ chí minh" lowercase with diacritics → finds HCM via name search', () => {
558
+ const r = vn.lookup('hồ chí minh');
559
+ expect(isSuccess(r)).toBe(true);
560
+ if (isSuccess(r)) {
561
+ expect(r.data.id).toBe('79');
562
+ expect(r.data.name).toBe('Thành phố Hồ Chí Minh');
563
+ }
564
+ });
565
+ it('UNICODE: "HÀ NỘI" uppercase with diacritics → finds Hà Nội via name search', () => {
566
+ const r = vn.lookup('HÀ NỘI');
567
+ expect(isSuccess(r)).toBe(true);
568
+ if (isSuccess(r)) {
569
+ expect(r.data.id).toBe('01');
570
+ expect(r.data.name).toBe('Thành phố Hà Nội');
571
+ }
572
+ });
573
+ // === Special characters ===
574
+ it('EDGE: "HCM!" with trailing special char → province_not_found', () => {
575
+ const r = vn.lookup('HCM!');
576
+ expect(isError(r)).toBe(true);
577
+ if (isError(r))
578
+ expect(r.error).toBe('province_not_found');
579
+ });
580
+ // === 2-digit numeric not in any data ===
581
+ it('EDGE: nonexistent 2-char "99" → province_not_found', () => {
582
+ const r = vn.lookup('99');
583
+ expect(isError(r)).toBe(true);
584
+ if (isError(r))
585
+ expect(r.error).toBe('province_not_found');
586
+ });
587
+ });
588
+ // ─── Block C: vn.search() — complete from scratch ───────
589
+ describe('vn.search() — complete test suite', () => {
590
+ // === Empty/edge inputs ===
591
+ it('EDGE: "" → ok([])', () => {
592
+ const r = vn.search('');
593
+ expect(isSuccess(r)).toBe(true);
594
+ if (isSuccess(r))
595
+ expect(r.data).toEqual([]);
596
+ });
597
+ it('EDGE: " " (whitespace) → ok([])', () => {
598
+ const r = vn.search(' ');
599
+ expect(isSuccess(r)).toBe(true);
600
+ if (isSuccess(r))
601
+ expect(r.data).toEqual([]);
602
+ });
603
+ it('EDGE: null → ok([])', () => {
604
+ const r = vn.search(null);
605
+ expect(isSuccess(r)).toBe(true);
606
+ if (isSuccess(r))
607
+ expect(r.data).toEqual([]);
608
+ });
609
+ // === Search provinces (no parentId) ===
610
+ it('EXACT MATCH: "Cà Mau" → returns exactly 1 province', () => {
611
+ const r = vn.search('Cà Mau');
612
+ expect(isSuccess(r)).toBe(true);
613
+ if (isSuccess(r)) {
614
+ expect(r.data).toHaveLength(1);
615
+ expect(r.data[0].name).toBe('Cà Mau');
616
+ }
617
+ });
618
+ it('PARTIAL MATCH: "Hà" → returns provinces containing "Hà" (multiple)', () => {
619
+ const r = vn.search('Hà');
620
+ expect(isSuccess(r)).toBe(true);
621
+ if (isSuccess(r)) {
622
+ expect(r.data.length).toBeGreaterThanOrEqual(3);
623
+ const names = r.data.map((d) => d.name);
624
+ expect(names).toContain('Thành phố Hà Nội');
625
+ expect(names).toContain('Hà Tĩnh');
626
+ }
627
+ });
628
+ it('NO MATCH: "XYZXYZ" → ok([])', () => {
629
+ const r = vn.search('XYZXYZ');
630
+ expect(isSuccess(r)).toBe(true);
631
+ if (isSuccess(r))
632
+ expect(r.data).toEqual([]);
633
+ });
634
+ it('UNICODE: "HÀ NỘI" uppercase diacritics → returns Hà Nội', () => {
635
+ const r = vn.search('HÀ NỘI');
636
+ expect(isSuccess(r)).toBe(true);
637
+ if (isSuccess(r)) {
638
+ expect(r.data).toHaveLength(1);
639
+ expect(r.data[0].name).toBe('Thành phố Hà Nội');
640
+ }
641
+ });
642
+ // === Search communes (with parentId) ===
643
+ it('PARTIAL MATCH: "Phường" with parentId "79" → returns matching wards in HCM', () => {
644
+ const r = vn.search('Phường', '79');
645
+ expect(isSuccess(r)).toBe(true);
646
+ if (isSuccess(r)) {
647
+ expect(r.data.length).toBeGreaterThan(0);
648
+ }
649
+ });
650
+ it('EXACT MATCH: "Ba Đình" with parentId "01" → returns matching commune in Hà Nội', () => {
651
+ const r = vn.search('Ba Đình', '01');
652
+ expect(isSuccess(r)).toBe(true);
653
+ if (isSuccess(r)) {
654
+ expect(r.data.length).toBeGreaterThanOrEqual(1);
655
+ expect(r.data[0].name).toBe('Phường Ba Đình');
656
+ }
657
+ });
658
+ it('NO MATCH: "XYZXYZ" with parentId "79" → ok([])', () => {
659
+ const r = vn.search('XYZXYZ', '79');
660
+ expect(isSuccess(r)).toBe(true);
661
+ if (isSuccess(r))
662
+ expect(r.data).toEqual([]);
663
+ });
664
+ it('EDGE: "" with parentId "79" → ok([])', () => {
665
+ const r = vn.search('', '79');
666
+ expect(isSuccess(r)).toBe(true);
667
+ if (isSuccess(r))
668
+ expect(r.data).toEqual([]);
669
+ });
670
+ it('EDGE: search with invalid parentId "99" → ok([]) because no communes', () => {
671
+ const r = vn.search('Phường', '99');
672
+ expect(isSuccess(r)).toBe(true);
673
+ if (isSuccess(r))
674
+ expect(r.data).toEqual([]);
675
+ });
676
+ });
677
+ // ─── Block D: vn.legacy.divisions() — edge cases ────────
678
+ describe('vn.legacy.divisions() — edge cases and boundary inputs', () => {
679
+ it('REGRESSION: null parentId → throws TypeError', () => {
680
+ expect(() => vn.legacy.divisions(null)).toThrow(TypeError);
681
+ });
682
+ it('EDGE: undefined parentId → treated as no-arg, returns 63 provinces', () => {
683
+ const r = vn.legacy.divisions(undefined);
684
+ expect(isSuccess(r)).toBe(true);
685
+ if (isSuccess(r))
686
+ expect(r.data).toHaveLength(63);
687
+ });
688
+ it('EDGE: whitespace " 01 " parentId → empty_district_list', () => {
689
+ const r = vn.legacy.divisions(' 01 ');
690
+ expect(isError(r)).toBe(true);
691
+ if (isError(r))
692
+ expect(r.error).toBe('empty_district_list');
693
+ });
694
+ it('EDGE: "0" parentId → empty_district_list', () => {
695
+ const r = vn.legacy.divisions('0');
696
+ expect(isError(r)).toBe(true);
697
+ if (isError(r))
698
+ expect(r.error).toBe('empty_district_list');
699
+ });
700
+ it('EDGE: special chars "!@#$%" parentId → empty_district_list', () => {
701
+ const r = vn.legacy.divisions('!@#$%');
702
+ expect(isError(r)).toBe(true);
703
+ if (isError(r))
704
+ expect(r.error).toBe('empty_district_list');
705
+ });
706
+ it('EDGE: 100-char parentId → empty_district_list', () => {
707
+ const r = vn.legacy.divisions('a'.repeat(100));
708
+ expect(isError(r)).toBe(true);
709
+ if (isError(r))
710
+ expect(r.error).toBe('empty_district_list');
711
+ });
712
+ });
713
+ // ─── Block E: vn.legacy.lookup() — complete from scratch ─
714
+ describe('vn.legacy.lookup() — complete test suite', () => {
715
+ // === Province ID (2-char) ===
716
+ it('EXACT MATCH: "01" → Hà Nội, kind: province', () => {
717
+ const r = vn.legacy.lookup('01');
718
+ expect(isSuccess(r)).toBe(true);
719
+ if (isSuccess(r)) {
720
+ expect(r.data).toEqual({
721
+ id: '01',
722
+ name: 'Thành phố Hà Nội',
723
+ kind: 'province',
724
+ });
725
+ }
726
+ });
727
+ it('EXACT MATCH: "79" → HCM, kind: province', () => {
728
+ const r = vn.legacy.lookup('79');
729
+ expect(isSuccess(r)).toBe(true);
730
+ if (isSuccess(r)) {
731
+ expect(r.data).toEqual({
732
+ id: '79',
733
+ name: 'Thành phố Hồ Chí Minh',
734
+ kind: 'province',
735
+ });
736
+ }
737
+ });
738
+ // === District ID (3-char) ===
739
+ it('EXACT MATCH: "001" → Quận Ba Đình, kind: district', () => {
740
+ const r = vn.legacy.lookup('001');
741
+ expect(isSuccess(r)).toBe(true);
742
+ if (isSuccess(r)) {
743
+ expect(r.data).toEqual({
744
+ id: '001',
745
+ name: 'Quận Ba Đình',
746
+ kind: 'district',
747
+ });
748
+ }
749
+ });
750
+ it('EXACT MATCH: "002" → Quận Hoàn Kiếm, kind: district', () => {
751
+ const r = vn.legacy.lookup('002');
752
+ expect(isSuccess(r)).toBe(true);
753
+ if (isSuccess(r)) {
754
+ expect(r.data).toEqual({
755
+ id: '002',
756
+ name: 'Quận Hoàn Kiếm',
757
+ kind: 'district',
758
+ });
759
+ }
760
+ });
761
+ // === Commune ID (5-char) ===
762
+ it('EXACT MATCH: "00001" → Phường Phúc Xá, kind: ward', () => {
763
+ const r = vn.legacy.lookup('00001');
764
+ expect(isSuccess(r)).toBe(true);
765
+ if (isSuccess(r)) {
766
+ expect(r.data).toEqual({
767
+ id: '00001',
768
+ name: 'Phường Phúc Xá',
769
+ kind: 'ward',
770
+ });
771
+ }
772
+ });
773
+ it('EXACT MATCH: "00004" → Phường Trúc Bạch, kind: ward', () => {
774
+ const r = vn.legacy.lookup('00004');
775
+ expect(isSuccess(r)).toBe(true);
776
+ if (isSuccess(r)) {
777
+ expect(r.data).toEqual({
778
+ id: '00004',
779
+ name: 'Phường Trúc Bạch',
780
+ kind: 'ward',
781
+ });
782
+ }
783
+ });
784
+ // === Name lookup ===
785
+ it('EXACT MATCH: "Thành phố Hà Nội" → exact match by name, kind: province', () => {
786
+ const r = vn.legacy.lookup('Thành phố Hà Nội');
787
+ expect(isSuccess(r)).toBe(true);
788
+ if (isSuccess(r)) {
789
+ expect(r.data.id).toBe('01');
790
+ expect(r.data.kind).toBe('province');
791
+ }
792
+ });
793
+ it('EXACT MATCH: "Tỉnh Hà Giang" → exact match by name, kind: province', () => {
794
+ const r = vn.legacy.lookup('Tỉnh Hà Giang');
795
+ expect(isSuccess(r)).toBe(true);
796
+ if (isSuccess(r)) {
797
+ expect(r.data.id).toBe('02');
798
+ expect(r.data.kind).toBe('province');
799
+ }
800
+ });
801
+ // === Edge/error ===
802
+ it('EDGE: "" → invalid_query', () => {
803
+ const r = vn.legacy.lookup('');
804
+ expect(isError(r)).toBe(true);
805
+ if (isError(r))
806
+ expect(r.error).toBe('invalid_query');
807
+ });
808
+ it('EDGE: "99" nonexistent 2-char → province_not_found', () => {
809
+ const r = vn.legacy.lookup('99');
810
+ expect(isError(r)).toBe(true);
811
+ if (isError(r))
812
+ expect(r.error).toBe('province_not_found');
813
+ });
814
+ it('EDGE: "999" nonexistent 3-char → province_not_found', () => {
815
+ const r = vn.legacy.lookup('999');
816
+ expect(isError(r)).toBe(true);
817
+ if (isError(r))
818
+ expect(r.error).toBe('province_not_found');
819
+ });
820
+ it('EDGE: "99999" nonexistent 5-char → province_not_found', () => {
821
+ const r = vn.legacy.lookup('99999');
822
+ expect(isError(r)).toBe(true);
823
+ if (isError(r))
824
+ expect(r.error).toBe('province_not_found');
825
+ });
826
+ it('EDGE: "XYZXYZ" → province_not_found', () => {
827
+ const r = vn.legacy.lookup('XYZXYZ');
828
+ expect(isError(r)).toBe(true);
829
+ if (isError(r))
830
+ expect(r.error).toBe('province_not_found');
831
+ });
832
+ });
833
+ // ─── Block F: vn.legacy.search() — complete from scratch ─
834
+ describe('vn.legacy.search() — complete test suite', () => {
835
+ it('EDGE: "" → ok([])', () => {
836
+ const r = vn.legacy.search('');
837
+ expect(isSuccess(r)).toBe(true);
838
+ if (isSuccess(r))
839
+ expect(r.data).toEqual([]);
840
+ });
841
+ it('EDGE: " " → ok([])', () => {
842
+ const r = vn.legacy.search(' ');
843
+ expect(isSuccess(r)).toBe(true);
844
+ if (isSuccess(r))
845
+ expect(r.data).toEqual([]);
846
+ });
847
+ it('EDGE: null → ok([])', () => {
848
+ const r = vn.legacy.search(null);
849
+ expect(isSuccess(r)).toBe(true);
850
+ if (isSuccess(r))
851
+ expect(r.data).toEqual([]);
852
+ });
853
+ // Search provinces (no parentId)
854
+ it('PARTIAL MATCH: "Hà" → multiple provinces', () => {
855
+ const r = vn.legacy.search('Hà');
856
+ expect(isSuccess(r)).toBe(true);
857
+ if (isSuccess(r)) {
858
+ expect(r.data.length).toBeGreaterThanOrEqual(3);
859
+ const names = r.data.map((d) => d.name);
860
+ expect(names).toContain('Thành phố Hà Nội');
861
+ expect(names).toContain('Tỉnh Hà Giang');
862
+ }
863
+ });
864
+ it('EXACT MATCH: "Cà Mau" → 1 province matches legacy name', () => {
865
+ const r = vn.legacy.search('Cà Mau');
866
+ expect(isSuccess(r)).toBe(true);
867
+ if (isSuccess(r)) {
868
+ expect(r.data).toHaveLength(1);
869
+ expect(r.data[0].name).toBe('Tỉnh Cà Mau');
870
+ }
871
+ });
872
+ it('NO MATCH: "XYZXYZ" → ok([])', () => {
873
+ const r = vn.legacy.search('XYZXYZ');
874
+ expect(isSuccess(r)).toBe(true);
875
+ if (isSuccess(r))
876
+ expect(r.data).toEqual([]);
877
+ });
878
+ // Search districts (with parentId)
879
+ it('PARTIAL MATCH: "Ba" with parentId "01" → districts containing "Ba"', () => {
880
+ const r = vn.legacy.search('Ba', '01');
881
+ expect(isSuccess(r)).toBe(true);
882
+ if (isSuccess(r)) {
883
+ expect(r.data.length).toBeGreaterThanOrEqual(2);
884
+ const names = r.data.map((d) => d.name);
885
+ expect(names).toContain('Quận Ba Đình');
886
+ expect(names).toContain('Huyện Ba Vì');
887
+ }
888
+ });
889
+ it('EXACT MATCH: "Quận Ba Đình" with parentId "01" → exactly Ba Đình', () => {
890
+ const r = vn.legacy.search('Quận Ba Đình', '01');
891
+ expect(isSuccess(r)).toBe(true);
892
+ if (isSuccess(r)) {
893
+ expect(r.data).toHaveLength(1);
894
+ expect(r.data[0].id).toBe('001');
895
+ }
896
+ });
897
+ it('NO MATCH: "XYZ" with parentId "01" → ok([])', () => {
898
+ const r = vn.legacy.search('XYZ', '01');
899
+ expect(isSuccess(r)).toBe(true);
900
+ if (isSuccess(r))
901
+ expect(r.data).toEqual([]);
902
+ });
903
+ it('EDGE: "" with parentId "01" → ok([])', () => {
904
+ const r = vn.legacy.search('', '01');
905
+ expect(isSuccess(r)).toBe(true);
906
+ if (isSuccess(r))
907
+ expect(r.data).toEqual([]);
908
+ });
909
+ });
910
+ // ─── Block G: Merge/mapping data integrity ──────────────
911
+ describe('data integrity — merge and mapping data', () => {
912
+ it('DATA_INTEGRITY: merge entries → all newProvinceId values exist in v3 provinces', () => {
913
+ const r = vn.divisions();
914
+ if (isSuccess(r)) {
915
+ const provIds = new Set(r.data.map((p) => p.id));
916
+ for (const merge of provinceMerges) {
917
+ expect(provIds.has(merge.newProvinceId)).toBe(true);
918
+ }
919
+ }
920
+ });
921
+ it('DATA_INTEGRITY: merge entries → no duplicate old province IDs', () => {
922
+ const oldIds = [];
923
+ for (const merge of provinceMerges) {
924
+ for (const item of merge.mergedFrom) {
925
+ oldIds.push(item.oldProvinceId);
926
+ }
927
+ }
928
+ expect(new Set(oldIds).size).toBe(oldIds.length);
929
+ });
930
+ it('DATA_INTEGRITY: ward mappings sample → first 100 newWardCode values exist in communes', () => {
931
+ const r = vn.divisions();
932
+ if (isSuccess(r)) {
933
+ const communeIds = new Set();
934
+ for (const p of r.data) {
935
+ const communes = vn.divisions(p.id);
936
+ if (isSuccess(communes)) {
937
+ for (const c of communes.data)
938
+ communeIds.add(c.id);
939
+ }
940
+ }
941
+ const rawMappings = wardMappings;
942
+ for (let i = 0; i < Math.min(100, rawMappings.length); i++) {
943
+ expect(communeIds.has(rawMappings[i].newWardCode)).toBe(true);
944
+ }
945
+ }
946
+ });
947
+ it('DATA_INTEGRITY: province_merges.json → non-empty dataset', () => {
948
+ expect(provinceMerges.length).toBeGreaterThan(0);
949
+ });
950
+ it('DATA_INTEGRITY: ward_mappings.json → non-empty dataset with expected fields', () => {
951
+ expect(wardMappings.length).toBeGreaterThan(0);
952
+ });
953
+ });
954
+ // ─── Block H: Regression — input typesafety ─────────────
955
+ describe('regression — runtime safety for JavaScript callers', () => {
956
+ it('DOCUMENTED: vn.divisions(null) → throws TypeError (JS caller without TS)', () => {
957
+ expect(() => vn.divisions(null)).toThrow(TypeError);
958
+ });
959
+ it('DOCUMENTED: vn.legacy.divisions(null) → throws TypeError (JS caller without TS)', () => {
960
+ expect(() => vn.legacy.divisions(null)).toThrow(TypeError);
961
+ });
962
+ });
963
+ // ─── Block I: vn.addressLookup() ──────────────────────────
964
+ describe('vn.addressLookup()', () => {
965
+ function ok(r) {
966
+ return r.data !== null && !('error' in r);
967
+ }
968
+ function err(r) {
969
+ return r.data === null && 'error' in r;
970
+ }
971
+ describe('full address — legacy 63 tỉnh', () => {
972
+ it('resolves "Thôn Nghĩa Thắng, Đông Hòa, TP Thái Bình, Thái Bình" → full legacy + v3', () => {
973
+ const r = vn.addressLookup('Thôn Nghĩa Thắng, Đông Hòa, TP Thái Bình, Thái Bình');
974
+ expect(ok(r)).toBe(true);
975
+ if (!ok(r))
976
+ return;
977
+ // Legacy
978
+ expect(r.data.legacy.province).toEqual({
979
+ id: '34',
980
+ name: 'Tỉnh Thái Bình',
981
+ });
982
+ expect(r.data.legacy.district).toEqual({
983
+ id: '336',
984
+ name: 'Thành phố Thái Bình',
985
+ });
986
+ expect(r.data.legacy.commune).toEqual({
987
+ id: '12457',
988
+ name: 'Xã Đông Hòa',
989
+ });
990
+ expect(r.data.legacy.hamlet).toBe('Thôn Nghĩa Thắng');
991
+ // V3 — cross-resolved via province_merges
992
+ expect(r.data.v3.province).toEqual({
993
+ id: '33',
994
+ name: 'Hưng Yên',
995
+ });
996
+ // Đông Hòa mapped to Trà Lý via ward_mappings
997
+ expect(r.data.v3.commune).toEqual({
998
+ id: '12817',
999
+ name: 'Phường Trà Lý',
1000
+ });
1001
+ expect(r.data.v3.hamlet).toBe('Thôn Nghĩa Thắng');
1002
+ });
1003
+ it('resolves without diacritics "Dong Hoa, TP Thai Binh, Thai Binh"', () => {
1004
+ const r = vn.addressLookup('Dong Hoa, TP Thai Binh, Thai Binh');
1005
+ expect(ok(r)).toBe(true);
1006
+ if (!ok(r))
1007
+ return;
1008
+ expect(r.data.legacy.province.id).toBe('34');
1009
+ expect(r.data.legacy.district.id).toBe('336');
1010
+ expect(r.data.legacy.commune.id).toBe('12457');
1011
+ expect(r.data.v3.province.id).toBe('33');
1012
+ });
1013
+ it('resolves province only "Thái Bình"', () => {
1014
+ const r = vn.addressLookup('Thái Bình');
1015
+ expect(ok(r)).toBe(true);
1016
+ if (!ok(r))
1017
+ return;
1018
+ expect(r.data.legacy.province.id).toBe('34');
1019
+ expect(r.data.legacy.district).toBeNull();
1020
+ expect(r.data.legacy.commune).toBeNull();
1021
+ expect(r.data.v3.province.id).toBe('33');
1022
+ });
1023
+ it('resolves province + district "TP Thái Bình, Thái Bình"', () => {
1024
+ const r = vn.addressLookup('TP Thái Bình, Thái Bình');
1025
+ expect(ok(r)).toBe(true);
1026
+ if (!ok(r))
1027
+ return;
1028
+ expect(r.data.legacy.province.id).toBe('34');
1029
+ expect(r.data.legacy.district.id).toBe('336');
1030
+ expect(r.data.legacy.commune).toBeNull();
1031
+ });
1032
+ });
1033
+ describe('cross-resolution — legacy province merged into v3', () => {
1034
+ it('"Bình Dương" → legacy 74 → v3 Hồ Chí Minh (79)', () => {
1035
+ const r = vn.addressLookup('Bình Dương');
1036
+ expect(ok(r)).toBe(true);
1037
+ if (!ok(r))
1038
+ return;
1039
+ expect(r.data.legacy.province.id).toBe('74');
1040
+ expect(r.data.v3.province.id).toBe('79');
1041
+ expect(r.data.v3.province.name).toBe('Thành phố Hồ Chí Minh');
1042
+ });
1043
+ it('"Ninh Thuận" → legacy 58 → v3 Khánh Hòa (56)', () => {
1044
+ const r = vn.addressLookup('Ninh Thuận');
1045
+ expect(ok(r)).toBe(true);
1046
+ if (!ok(r))
1047
+ return;
1048
+ expect(r.data.legacy.province.id).toBe('58');
1049
+ expect(r.data.v3.province.id).toBe('56');
1050
+ });
1051
+ });
1052
+ describe('strict error handling (Option B)', () => {
1053
+ it('empty string → invalid_query', () => {
1054
+ const r = vn.addressLookup('');
1055
+ expect(err(r)).toBe(true);
1056
+ if (err(r))
1057
+ expect(r.error).toBe('invalid_query');
1058
+ });
1059
+ it('whitespace only → invalid_query', () => {
1060
+ const r = vn.addressLookup(' ');
1061
+ expect(err(r)).toBe(true);
1062
+ if (err(r))
1063
+ expect(r.error).toBe('invalid_query');
1064
+ });
1065
+ it('no province in address → address_missing_province', () => {
1066
+ const r = vn.addressLookup('Xã ABC, Huyện XYZ');
1067
+ expect(err(r)).toBe(true);
1068
+ if (err(r))
1069
+ expect(r.error).toBe('address_ambiguous_province');
1070
+ // Note: "Xã ABC" has no detectable province, province is "ABC"
1071
+ // which doesn't match any real province → ambiguous
1072
+ });
1073
+ it('nonexistent province → address_ambiguous_province', () => {
1074
+ const r = vn.addressLookup('Somewhere, Nowhereland');
1075
+ expect(err(r)).toBe(true);
1076
+ if (err(r))
1077
+ expect(r.error).toBe('address_ambiguous_province');
1078
+ });
1079
+ });
1080
+ describe('v3 direct match — province exists in both systems', () => {
1081
+ it('"Hồ Chí Minh" → legacy 79 + v3 79 (same)', () => {
1082
+ const r = vn.addressLookup('Hồ Chí Minh');
1083
+ expect(ok(r)).toBe(true);
1084
+ if (!ok(r))
1085
+ return;
1086
+ expect(r.data.legacy.province.id).toBe('79');
1087
+ expect(r.data.v3.province.id).toBe('79');
1088
+ expect(r.data.v3.province.name).toBe('Thành phố Hồ Chí Minh');
1089
+ });
1090
+ it('"Hà Nội" → legacy 01 + v3 01', () => {
1091
+ const r = vn.addressLookup('Hà Nội');
1092
+ expect(ok(r)).toBe(true);
1093
+ if (!ok(r))
1094
+ return;
1095
+ expect(r.data.legacy.province.id).toBe('01');
1096
+ expect(r.data.v3.province.id).toBe('01');
1097
+ });
1098
+ it('"HNI" → v3 01 (Hà Nội) via abbreviation expansion', () => {
1099
+ const r = vn.addressLookup('HNI');
1100
+ expect(ok(r)).toBe(true);
1101
+ if (!ok(r))
1102
+ return;
1103
+ expect(r.data.v3.province.id).toBe('01');
1104
+ });
1105
+ });
1106
+ });
1107
+ //# sourceMappingURL=vn.test.js.map