@terreno/feature-flags 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,603 @@
1
+ import {describe, expect, it} from "bun:test";
2
+ import {deterministicHash, evaluateAllFlags, evaluateFlag} from "../evaluate";
3
+ import type {FeatureFlagDocument, FeatureFlagModel, SegmentFunction} from "../types";
4
+
5
+ const createFlag = (overrides: Partial<FeatureFlagDocument> = {}): FeatureFlagDocument => {
6
+ return {
7
+ archived: false,
8
+ description: "Test flag",
9
+ enabled: true,
10
+ key: "test-flag",
11
+ name: "Test Flag",
12
+ rolloutPercentage: 100,
13
+ rules: [],
14
+ type: "boolean",
15
+ variants: [],
16
+ ...overrides,
17
+ } as unknown as FeatureFlagDocument;
18
+ };
19
+
20
+ const noSegments: Record<string, SegmentFunction> = {};
21
+
22
+ describe("deterministicHash", () => {
23
+ it("returns a number between 0 and 99", () => {
24
+ for (let i = 0; i < 100; i++) {
25
+ const hash = deterministicHash(`user-${i}flag-key`);
26
+ expect(hash).toBeGreaterThanOrEqual(0);
27
+ expect(hash).toBeLessThan(100);
28
+ }
29
+ });
30
+
31
+ it("returns the same value for the same input", () => {
32
+ const input = "user123test-flag";
33
+ const hash1 = deterministicHash(input);
34
+ const hash2 = deterministicHash(input);
35
+ expect(hash1).toBe(hash2);
36
+ });
37
+
38
+ it("returns different values for different inputs", () => {
39
+ const hash1 = deterministicHash("user1test-flag");
40
+ const hash2 = deterministicHash("user2test-flag");
41
+ // Not guaranteed but extremely likely for different inputs
42
+ // Just verify they're both valid
43
+ expect(hash1).toBeGreaterThanOrEqual(0);
44
+ expect(hash2).toBeGreaterThanOrEqual(0);
45
+ });
46
+
47
+ it("does not collide for ambiguous userId/key pairs", () => {
48
+ // "12" + "3" must not equal "1" + "23" — delimiter prevents this
49
+ const hash1 = deterministicHash("12:3");
50
+ const hash2 = deterministicHash("1:23");
51
+ expect(hash1).not.toBe(hash2);
52
+ });
53
+ });
54
+
55
+ describe("evaluateFlag", () => {
56
+ describe("disabled flags", () => {
57
+ it("returns false for disabled boolean flags", () => {
58
+ const flag = createFlag({enabled: false, type: "boolean"});
59
+ expect(evaluateFlag(flag, "user1", {}, noSegments)).toBe(false);
60
+ });
61
+
62
+ it("returns null for disabled variant flags", () => {
63
+ const flag = createFlag({
64
+ enabled: false,
65
+ type: "variant",
66
+ variants: [
67
+ {key: "control", weight: 50},
68
+ {key: "variant-a", weight: 50},
69
+ ],
70
+ });
71
+ expect(evaluateFlag(flag, "user1", {}, noSegments)).toBeNull();
72
+ });
73
+ });
74
+
75
+ describe("boolean flags with rollout percentage", () => {
76
+ it("returns true for all users at 100% rollout", () => {
77
+ const flag = createFlag({rolloutPercentage: 100});
78
+ // Test multiple users — all should get true
79
+ for (let i = 0; i < 20; i++) {
80
+ expect(evaluateFlag(flag, `user-${i}`, {}, noSegments)).toBe(true);
81
+ }
82
+ });
83
+
84
+ it("returns false for all users at 0% rollout", () => {
85
+ const flag = createFlag({rolloutPercentage: 0});
86
+ for (let i = 0; i < 20; i++) {
87
+ expect(evaluateFlag(flag, `user-${i}`, {}, noSegments)).toBe(false);
88
+ }
89
+ });
90
+
91
+ it("gives consistent results for the same user", () => {
92
+ const flag = createFlag({rolloutPercentage: 50});
93
+ const result1 = evaluateFlag(flag, "user-abc", {}, noSegments);
94
+ const result2 = evaluateFlag(flag, "user-abc", {}, noSegments);
95
+ expect(result1).toBe(result2);
96
+ });
97
+
98
+ it("distributes roughly proportionally at 50%", () => {
99
+ const flag = createFlag({rolloutPercentage: 50});
100
+ let trueCount = 0;
101
+ const total = 1000;
102
+ for (let i = 0; i < total; i++) {
103
+ if (evaluateFlag(flag, `user-${i}`, {}, noSegments) === true) {
104
+ trueCount++;
105
+ }
106
+ }
107
+ // Allow 15% tolerance for randomness
108
+ expect(trueCount).toBeGreaterThan(total * 0.35);
109
+ expect(trueCount).toBeLessThan(total * 0.65);
110
+ });
111
+ });
112
+
113
+ describe("variant flags", () => {
114
+ it("assigns users to variants based on weights", () => {
115
+ const flag = createFlag({
116
+ type: "variant",
117
+ variants: [
118
+ {key: "control", weight: 50},
119
+ {key: "variant-a", weight: 50},
120
+ ],
121
+ });
122
+
123
+ const counts: Record<string, number> = {control: 0, "variant-a": 0};
124
+ const total = 1000;
125
+ for (let i = 0; i < total; i++) {
126
+ const result = evaluateFlag(flag, `user-${i}`, {}, noSegments) as string;
127
+ counts[result]++;
128
+ }
129
+
130
+ // Both variants should get roughly 50%
131
+ expect(counts.control).toBeGreaterThan(total * 0.35);
132
+ expect(counts["variant-a"]).toBeGreaterThan(total * 0.35);
133
+ });
134
+
135
+ it("returns consistent variant for the same user", () => {
136
+ const flag = createFlag({
137
+ type: "variant",
138
+ variants: [
139
+ {key: "control", weight: 50},
140
+ {key: "variant-a", weight: 50},
141
+ ],
142
+ });
143
+
144
+ const result1 = evaluateFlag(flag, "user-123", {}, noSegments);
145
+ const result2 = evaluateFlag(flag, "user-123", {}, noSegments);
146
+ expect(result1).toBe(result2);
147
+ });
148
+
149
+ it("handles three-way split", () => {
150
+ const flag = createFlag({
151
+ type: "variant",
152
+ variants: [
153
+ {key: "control", weight: 50},
154
+ {key: "variant-a", weight: 30},
155
+ {key: "variant-b", weight: 20},
156
+ ],
157
+ });
158
+
159
+ const counts: Record<string, number> = {control: 0, "variant-a": 0, "variant-b": 0};
160
+ const total = 1000;
161
+ for (let i = 0; i < total; i++) {
162
+ const result = evaluateFlag(flag, `user-${i}`, {}, noSegments) as string;
163
+ counts[result]++;
164
+ }
165
+
166
+ // All variants should get some users
167
+ expect(counts.control).toBeGreaterThan(0);
168
+ expect(counts["variant-a"]).toBeGreaterThan(0);
169
+ expect(counts["variant-b"]).toBeGreaterThan(0);
170
+ });
171
+ });
172
+
173
+ describe("field rules", () => {
174
+ it("matches eq operator", () => {
175
+ const flag = createFlag({
176
+ rules: [{enabled: true, field: "admin", operator: "eq", value: true}],
177
+ });
178
+
179
+ expect(evaluateFlag(flag, "user1", {admin: true}, noSegments)).toBe(true);
180
+ expect(evaluateFlag(flag, "user2", {admin: false}, noSegments)).toBe(true); // Falls through to rollout at 100%
181
+ });
182
+
183
+ it("matches neq operator", () => {
184
+ const flag = createFlag({
185
+ rolloutPercentage: 0,
186
+ rules: [{enabled: true, field: "role", operator: "neq", value: "guest"}],
187
+ });
188
+
189
+ expect(evaluateFlag(flag, "user1", {role: "admin"}, noSegments)).toBe(true);
190
+ expect(evaluateFlag(flag, "user2", {role: "guest"}, noSegments)).toBe(false); // No match, 0% rollout
191
+ });
192
+
193
+ it("matches in operator", () => {
194
+ const flag = createFlag({
195
+ rolloutPercentage: 0,
196
+ rules: [{enabled: true, field: "plan", operator: "in", value: ["pro", "enterprise"]}],
197
+ });
198
+
199
+ expect(evaluateFlag(flag, "user1", {plan: "pro"}, noSegments)).toBe(true);
200
+ expect(evaluateFlag(flag, "user2", {plan: "free"}, noSegments)).toBe(false);
201
+ });
202
+
203
+ it("matches nin operator", () => {
204
+ const flag = createFlag({
205
+ rolloutPercentage: 0,
206
+ rules: [{enabled: true, field: "plan", operator: "nin", value: ["free", "trial"]}],
207
+ });
208
+
209
+ expect(evaluateFlag(flag, "user1", {plan: "pro"}, noSegments)).toBe(true);
210
+ expect(evaluateFlag(flag, "user2", {plan: "free"}, noSegments)).toBe(false);
211
+ });
212
+
213
+ it("matches gt operator", () => {
214
+ const flag = createFlag({
215
+ rolloutPercentage: 0,
216
+ rules: [{enabled: true, field: "totalActions", operator: "gt", value: 100}],
217
+ });
218
+
219
+ expect(evaluateFlag(flag, "user1", {totalActions: 200}, noSegments)).toBe(true);
220
+ expect(evaluateFlag(flag, "user2", {totalActions: 50}, noSegments)).toBe(false);
221
+ });
222
+
223
+ it("matches lt operator", () => {
224
+ const flag = createFlag({
225
+ rolloutPercentage: 0,
226
+ rules: [{enabled: true, field: "age", operator: "lt", value: 18}],
227
+ });
228
+
229
+ expect(evaluateFlag(flag, "user1", {age: 15}, noSegments)).toBe(true);
230
+ expect(evaluateFlag(flag, "user2", {age: 25}, noSegments)).toBe(false);
231
+ });
232
+
233
+ it("matches contains operator", () => {
234
+ const flag = createFlag({
235
+ rolloutPercentage: 0,
236
+ rules: [{enabled: true, field: "email", operator: "contains", value: "@company.com"}],
237
+ });
238
+
239
+ expect(evaluateFlag(flag, "user1", {email: "john@company.com"}, noSegments)).toBe(true);
240
+ expect(evaluateFlag(flag, "user2", {email: "john@gmail.com"}, noSegments)).toBe(false);
241
+ });
242
+
243
+ it("does not match eq when rule.value is undefined", () => {
244
+ const flag = createFlag({
245
+ rolloutPercentage: 0,
246
+ rules: [{enabled: true, field: "missingField", operator: "eq"}],
247
+ });
248
+
249
+ // Both user field and rule.value are undefined — should NOT match
250
+ expect(evaluateFlag(flag, "user1", {}, noSegments)).toBe(false);
251
+ });
252
+
253
+ it("does not match neq when rule.value is undefined", () => {
254
+ const flag = createFlag({
255
+ rolloutPercentage: 0,
256
+ rules: [{enabled: true, field: "missingField", operator: "neq"}],
257
+ });
258
+
259
+ expect(evaluateFlag(flag, "user1", {}, noSegments)).toBe(false);
260
+ });
261
+
262
+ it("supports dot notation for nested fields", () => {
263
+ const flag = createFlag({
264
+ rolloutPercentage: 0,
265
+ rules: [{enabled: true, field: "address.zip", operator: "eq", value: "94105"}],
266
+ });
267
+
268
+ expect(evaluateFlag(flag, "user1", {address: {zip: "94105"}}, noSegments)).toBe(true);
269
+ expect(evaluateFlag(flag, "user2", {address: {zip: "10001"}}, noSegments)).toBe(false);
270
+ });
271
+
272
+ it("returns rule.enabled value for boolean flags", () => {
273
+ const flag = createFlag({
274
+ rules: [{enabled: false, field: "admin", operator: "eq", value: true}],
275
+ });
276
+
277
+ // Rule matches and returns enabled: false
278
+ expect(evaluateFlag(flag, "user1", {admin: true}, noSegments)).toBe(false);
279
+ });
280
+
281
+ it("first matching rule wins", () => {
282
+ const flag = createFlag({
283
+ rolloutPercentage: 0,
284
+ rules: [
285
+ {enabled: true, field: "admin", operator: "eq", value: true},
286
+ {enabled: false, field: "role", operator: "eq", value: "manager"},
287
+ ],
288
+ });
289
+
290
+ // Admin manager — first rule matches, gets true
291
+ expect(evaluateFlag(flag, "user1", {admin: true, role: "manager"}, noSegments)).toBe(true);
292
+ });
293
+ });
294
+
295
+ describe("variant flag rules", () => {
296
+ it("returns forced variant from matching rule", () => {
297
+ const flag = createFlag({
298
+ rules: [{field: "admin", operator: "eq", value: true, variant: "variant-a"}],
299
+ type: "variant",
300
+ variants: [
301
+ {key: "control", weight: 50},
302
+ {key: "variant-a", weight: 50},
303
+ ],
304
+ });
305
+
306
+ expect(evaluateFlag(flag, "user1", {admin: true}, noSegments)).toBe("variant-a");
307
+ });
308
+ });
309
+
310
+ describe("segment rules", () => {
311
+ it("matches segment function", () => {
312
+ const segments: Record<string, SegmentFunction> = {
313
+ "pro-users": (user: unknown) => (user as {plan: string}).plan === "pro",
314
+ };
315
+
316
+ const flag = createFlag({
317
+ rolloutPercentage: 0,
318
+ rules: [{enabled: true, segment: "pro-users"}],
319
+ });
320
+
321
+ expect(evaluateFlag(flag, "user1", {plan: "pro"}, segments)).toBe(true);
322
+ expect(evaluateFlag(flag, "user2", {plan: "free"}, segments)).toBe(false);
323
+ });
324
+
325
+ it("returns false for unknown segment", () => {
326
+ const flag = createFlag({
327
+ rolloutPercentage: 0,
328
+ rules: [{enabled: true, segment: "unknown-segment"}],
329
+ });
330
+
331
+ expect(evaluateFlag(flag, "user1", {}, noSegments)).toBe(false);
332
+ });
333
+
334
+ it("returns false when segment function throws", () => {
335
+ const segments: Record<string, SegmentFunction> = {
336
+ "throwing-segment": (): boolean => {
337
+ throw new Error("boom");
338
+ },
339
+ };
340
+
341
+ const flag = createFlag({
342
+ rolloutPercentage: 0,
343
+ rules: [{enabled: true, segment: "throwing-segment"}],
344
+ });
345
+
346
+ expect(evaluateFlag(flag, "user1", {}, segments)).toBe(false);
347
+ });
348
+
349
+ it("uses segment rule for variant flags", () => {
350
+ const segments: Record<string, SegmentFunction> = {
351
+ "pro-users": (user: unknown) => (user as {plan: string}).plan === "pro",
352
+ };
353
+
354
+ const flag = createFlag({
355
+ rules: [{segment: "pro-users", variant: "variant-a"}],
356
+ type: "variant",
357
+ variants: [
358
+ {key: "control", weight: 50},
359
+ {key: "variant-a", weight: 50},
360
+ ],
361
+ });
362
+
363
+ expect(evaluateFlag(flag, "user1", {plan: "pro"}, segments)).toBe("variant-a");
364
+ });
365
+ });
366
+
367
+ describe("malformed field rules", () => {
368
+ it("does not match a rule with no field and no segment", () => {
369
+ const flag = createFlag({
370
+ rolloutPercentage: 0,
371
+ rules: [{enabled: true}],
372
+ });
373
+
374
+ expect(evaluateFlag(flag, "user1", {anything: true}, noSegments)).toBe(false);
375
+ });
376
+
377
+ it("does not match a rule with a field but no operator", () => {
378
+ const flag = createFlag({
379
+ rolloutPercentage: 0,
380
+ rules: [{enabled: true, field: "admin"}],
381
+ });
382
+
383
+ expect(evaluateFlag(flag, "user1", {admin: true}, noSegments)).toBe(false);
384
+ });
385
+
386
+ it("does not match a rule with an unknown operator", () => {
387
+ const flag = createFlag({
388
+ rolloutPercentage: 0,
389
+ rules: [
390
+ {
391
+ enabled: true,
392
+ field: "admin",
393
+ operator: "bogus" as unknown as "eq",
394
+ value: true,
395
+ },
396
+ ],
397
+ });
398
+
399
+ expect(evaluateFlag(flag, "user1", {admin: true}, noSegments)).toBe(false);
400
+ });
401
+
402
+ it("does not match gt/lt when values are not numbers", () => {
403
+ const gtFlag = createFlag({
404
+ rolloutPercentage: 0,
405
+ rules: [{enabled: true, field: "age", operator: "gt", value: 18}],
406
+ });
407
+ expect(evaluateFlag(gtFlag, "user1", {age: "twenty"}, noSegments)).toBe(false);
408
+
409
+ const ltFlag = createFlag({
410
+ rolloutPercentage: 0,
411
+ rules: [{enabled: true, field: "age", operator: "lt", value: 18}],
412
+ });
413
+ expect(evaluateFlag(ltFlag, "user1", {age: undefined}, noSegments)).toBe(false);
414
+ });
415
+
416
+ it("does not match contains when values are not strings", () => {
417
+ const flag = createFlag({
418
+ rolloutPercentage: 0,
419
+ rules: [{enabled: true, field: "email", operator: "contains", value: "@company.com"}],
420
+ });
421
+
422
+ expect(evaluateFlag(flag, "user1", {email: 42}, noSegments)).toBe(false);
423
+ });
424
+
425
+ it("does not match in/nin when rule.value is not an array", () => {
426
+ const inFlag = createFlag({
427
+ rolloutPercentage: 0,
428
+ rules: [{enabled: true, field: "plan", operator: "in", value: "pro"}],
429
+ });
430
+ expect(evaluateFlag(inFlag, "user1", {plan: "pro"}, noSegments)).toBe(false);
431
+
432
+ const ninFlag = createFlag({
433
+ rolloutPercentage: 0,
434
+ rules: [{enabled: true, field: "plan", operator: "nin", value: "pro"}],
435
+ });
436
+ expect(evaluateFlag(ninFlag, "user1", {plan: "pro"}, noSegments)).toBe(false);
437
+ });
438
+
439
+ it("treats rule.enabled as false by default when undefined on a matching boolean rule", () => {
440
+ const flag = createFlag({
441
+ rolloutPercentage: 100,
442
+ rules: [{field: "admin", operator: "eq", value: true}],
443
+ });
444
+
445
+ // rule matches, but rule.enabled is undefined → evaluates to false
446
+ expect(evaluateFlag(flag, "user1", {admin: true}, noSegments)).toBe(false);
447
+ });
448
+
449
+ it("treats rule.variant as null when undefined on a matching variant rule", () => {
450
+ const flag = createFlag({
451
+ rules: [{field: "admin", operator: "eq", value: true}],
452
+ type: "variant",
453
+ variants: [
454
+ {key: "control", weight: 50},
455
+ {key: "variant-a", weight: 50},
456
+ ],
457
+ });
458
+
459
+ expect(evaluateFlag(flag, "user1", {admin: true}, noSegments)).toBeNull();
460
+ });
461
+ });
462
+
463
+ describe("variant fallback behavior", () => {
464
+ it("falls back to the last variant when weights do not sum to 100", () => {
465
+ const flag = createFlag({
466
+ type: "variant",
467
+ // Weights only sum to 10 — the hash will always exceed cumulative weight
468
+ variants: [
469
+ {key: "a", weight: 5},
470
+ {key: "b", weight: 5},
471
+ ],
472
+ });
473
+
474
+ // Pick a user whose hash is guaranteed >= 10
475
+ const userId = "this-user-hashes-high";
476
+ const hash = deterministicHash(`${userId}:${flag.key}`);
477
+ // Make sure our assumption holds before asserting the fallback
478
+ expect(hash).toBeGreaterThanOrEqual(10);
479
+
480
+ expect(evaluateFlag(flag, userId, {}, noSegments)).toBe("b");
481
+ });
482
+
483
+ it("returns null when an enabled variant flag has no variants defined", () => {
484
+ const flag = createFlag({
485
+ type: "variant",
486
+ variants: [],
487
+ });
488
+
489
+ expect(evaluateFlag(flag, "user1", {}, noSegments)).toBeNull();
490
+ });
491
+ });
492
+
493
+ describe("debug logging toggle", () => {
494
+ it("runs without logging when FEATURE_FLAGS_DEBUG is 'false'", () => {
495
+ const original = process.env.FEATURE_FLAGS_DEBUG;
496
+ process.env.FEATURE_FLAGS_DEBUG = "false";
497
+ try {
498
+ const flag = createFlag({
499
+ rolloutPercentage: 100,
500
+ rules: [{enabled: true, field: "admin", operator: "eq", value: true}],
501
+ });
502
+ expect(evaluateFlag(flag, "user1", {admin: true}, noSegments)).toBe(true);
503
+ expect(evaluateFlag(flag, "user2", {admin: false}, noSegments)).toBe(true);
504
+
505
+ const disabledFlag = createFlag({enabled: false});
506
+ expect(evaluateFlag(disabledFlag, "user3", {}, noSegments)).toBe(false);
507
+ } finally {
508
+ if (original === undefined) {
509
+ delete process.env.FEATURE_FLAGS_DEBUG;
510
+ } else {
511
+ process.env.FEATURE_FLAGS_DEBUG = original;
512
+ }
513
+ }
514
+ });
515
+ });
516
+ });
517
+
518
+ describe("evaluateAllFlags", () => {
519
+ const makeMockModel = (flags: FeatureFlagDocument[]): FeatureFlagModel => {
520
+ return {
521
+ find: async (query: Record<string, unknown>) => {
522
+ return flags.filter((flag) => {
523
+ if (
524
+ query.archived &&
525
+ typeof query.archived === "object" &&
526
+ "$ne" in (query.archived as Record<string, unknown>)
527
+ ) {
528
+ if (flag.archived) {
529
+ return false;
530
+ }
531
+ }
532
+ if (query.enabled !== undefined && flag.enabled !== query.enabled) {
533
+ return false;
534
+ }
535
+ return true;
536
+ });
537
+ },
538
+ } as unknown as FeatureFlagModel;
539
+ };
540
+
541
+ it("returns an empty object when there are no flags", async () => {
542
+ const model = makeMockModel([]);
543
+ const result = await evaluateAllFlags(model, "user1", {}, noSegments);
544
+ expect(result).toEqual({});
545
+ });
546
+
547
+ it("evaluates each enabled flag and keys results by flag key", async () => {
548
+ const flags = [
549
+ createFlag({key: "flag-a", rolloutPercentage: 100}),
550
+ createFlag({key: "flag-b", rolloutPercentage: 0}),
551
+ createFlag({
552
+ key: "flag-c",
553
+ rolloutPercentage: 0,
554
+ rules: [{enabled: true, field: "admin", operator: "eq", value: true}],
555
+ }),
556
+ ];
557
+
558
+ const model = makeMockModel(flags);
559
+ const result = await evaluateAllFlags(model, "user1", {admin: true}, noSegments);
560
+
561
+ expect(result["flag-a"]).toBe(true);
562
+ expect(result["flag-b"]).toBe(false);
563
+ expect(result["flag-c"]).toBe(true);
564
+ });
565
+
566
+ it("supports variant flags in the aggregate evaluation", async () => {
567
+ const flags = [
568
+ createFlag({
569
+ key: "variant-flag",
570
+ rules: [{segment: "pros", variant: "variant-a"}],
571
+ type: "variant",
572
+ variants: [
573
+ {key: "control", weight: 50},
574
+ {key: "variant-a", weight: 50},
575
+ ],
576
+ }),
577
+ ];
578
+
579
+ const segments: Record<string, SegmentFunction> = {
580
+ pros: (user: unknown) => (user as {pro: boolean}).pro === true,
581
+ };
582
+
583
+ const model = makeMockModel(flags);
584
+ const result = await evaluateAllFlags(model, "user1", {pro: true}, segments);
585
+ expect(result["variant-flag"]).toBe("variant-a");
586
+ });
587
+
588
+ it("runs with debug logging disabled", async () => {
589
+ const original = process.env.FEATURE_FLAGS_DEBUG;
590
+ process.env.FEATURE_FLAGS_DEBUG = "false";
591
+ try {
592
+ const model = makeMockModel([createFlag({key: "flag-a"})]);
593
+ const result = await evaluateAllFlags(model, "user1", {}, noSegments);
594
+ expect(result["flag-a"]).toBe(true);
595
+ } finally {
596
+ if (original === undefined) {
597
+ delete process.env.FEATURE_FLAGS_DEBUG;
598
+ } else {
599
+ process.env.FEATURE_FLAGS_DEBUG = original;
600
+ }
601
+ }
602
+ });
603
+ });