]>
Commit | Line | Data |
---|---|---|
11fdf7f2 | 1 | import { HttpClientTestingModule } from '@angular/common/http/testing'; |
1911f103 | 2 | import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; |
11fdf7f2 | 3 | import { ReactiveFormsModule } from '@angular/forms'; |
f67539c2 | 4 | import { By } from '@angular/platform-browser'; |
494da23a | 5 | import { ActivatedRoute, Router } from '@angular/router'; |
11fdf7f2 | 6 | import { RouterTestingModule } from '@angular/router/testing'; |
11fdf7f2 | 7 | |
494da23a | 8 | import { ToastrModule } from 'ngx-toastr'; |
1911f103 | 9 | import { NEVER, of } from 'rxjs'; |
eafe8130 TL |
10 | import { delay } from 'rxjs/operators'; |
11 | ||
f67539c2 TL |
12 | import { Pool } from '~/app/ceph/pool/pool'; |
13 | import { PoolService } from '~/app/shared/api/pool.service'; | |
14 | import { RbdService } from '~/app/shared/api/rbd.service'; | |
15 | import { ImageSpec } from '~/app/shared/models/image-spec'; | |
16 | import { SharedModule } from '~/app/shared/shared.module'; | |
17 | import { ActivatedRouteStub } from '~/testing/activated-route-stub'; | |
18 | import { configureTestBed } from '~/testing/unit-test-helper'; | |
11fdf7f2 | 19 | import { RbdConfigurationFormComponent } from '../rbd-configuration-form/rbd-configuration-form.component'; |
9f95a23c | 20 | import { RbdImageFeature } from './rbd-feature.interface'; |
11fdf7f2 | 21 | import { RbdFormMode } from './rbd-form-mode.enum'; |
f67539c2 | 22 | import { RbdFormResponseModel } from './rbd-form-response.model'; |
11fdf7f2 TL |
23 | import { RbdFormComponent } from './rbd-form.component'; |
24 | ||
25 | describe('RbdFormComponent', () => { | |
f67539c2 TL |
26 | const urlPrefix = { |
27 | create: '/block/rbd/create', | |
28 | edit: '/block/rbd/edit', | |
29 | clone: '/block/rbd/clone', | |
30 | copy: '/block/rbd/copy' | |
31 | }; | |
11fdf7f2 TL |
32 | let component: RbdFormComponent; |
33 | let fixture: ComponentFixture<RbdFormComponent>; | |
34 | let activatedRoute: ActivatedRouteStub; | |
f67539c2 TL |
35 | const mock: { rbd: RbdFormResponseModel; pools: Pool[]; defaultFeatures: string[] } = { |
36 | rbd: {} as RbdFormResponseModel, | |
37 | pools: [], | |
38 | defaultFeatures: [] | |
39 | }; | |
40 | ||
41 | const setRouterUrl = ( | |
42 | action: 'create' | 'edit' | 'clone' | 'copy', | |
43 | poolName?: string, | |
44 | imageName?: string | |
45 | ) => { | |
46 | component['routerUrl'] = [urlPrefix[action], poolName, imageName].filter((x) => x).join('/'); | |
47 | }; | |
11fdf7f2 | 48 | |
9f95a23c | 49 | const queryNativeElement = (cssSelector: string) => |
494da23a TL |
50 | fixture.debugElement.query(By.css(cssSelector)).nativeElement; |
51 | ||
11fdf7f2 TL |
52 | configureTestBed({ |
53 | imports: [ | |
54 | HttpClientTestingModule, | |
55 | ReactiveFormsModule, | |
56 | RouterTestingModule, | |
494da23a | 57 | ToastrModule.forRoot(), |
f67539c2 | 58 | SharedModule |
11fdf7f2 TL |
59 | ], |
60 | declarations: [RbdFormComponent, RbdConfigurationFormComponent], | |
61 | providers: [ | |
62 | { | |
63 | provide: ActivatedRoute, | |
64 | useValue: new ActivatedRouteStub({ pool: 'foo', name: 'bar', snap: undefined }) | |
65 | }, | |
494da23a | 66 | RbdService |
11fdf7f2 TL |
67 | ] |
68 | }); | |
69 | ||
70 | beforeEach(() => { | |
71 | fixture = TestBed.createComponent(RbdFormComponent); | |
72 | component = fixture.componentInstance; | |
f67539c2 TL |
73 | activatedRoute = <ActivatedRouteStub>TestBed.inject(ActivatedRoute); |
74 | ||
75 | component.loadingReady(); | |
11fdf7f2 TL |
76 | }); |
77 | ||
78 | it('should create', () => { | |
79 | expect(component).toBeTruthy(); | |
80 | }); | |
81 | ||
eafe8130 | 82 | describe('create/edit/clone/copy image', () => { |
9f95a23c TL |
83 | let createAction: jasmine.Spy; |
84 | let editAction: jasmine.Spy; | |
85 | let cloneAction: jasmine.Spy; | |
86 | let copyAction: jasmine.Spy; | |
87 | let rbdServiceGetSpy: jasmine.Spy; | |
1911f103 TL |
88 | let routerNavigate: jasmine.Spy; |
89 | ||
90 | const DELAY = 100; | |
eafe8130 | 91 | |
f67539c2 TL |
92 | const getPool = ( |
93 | pool_name: string, | |
94 | type: 'replicated' | 'erasure', | |
95 | flags_names: string, | |
96 | application_metadata: string[] | |
97 | ): Pool => | |
98 | ({ | |
99 | pool_name, | |
100 | flags_names, | |
101 | application_metadata, | |
102 | type | |
103 | } as Pool); | |
104 | ||
eafe8130 | 105 | beforeEach(() => { |
1911f103 TL |
106 | createAction = spyOn(component, 'createAction').and.returnValue(of(null)); |
107 | editAction = spyOn(component, 'editAction'); | |
108 | editAction.and.returnValue(of(null)); | |
109 | cloneAction = spyOn(component, 'cloneAction').and.returnValue(of(null)); | |
110 | copyAction = spyOn(component, 'copyAction').and.returnValue(of(null)); | |
eafe8130 | 111 | spyOn(component, 'setResponse').and.stub(); |
f67539c2 TL |
112 | routerNavigate = spyOn(TestBed.inject(Router), 'navigate').and.stub(); |
113 | mock.pools = [ | |
114 | getPool('one', 'replicated', '', []), | |
115 | getPool('two', 'replicated', '', ['rbd']), | |
116 | getPool('three', 'replicated', '', ['rbd']), | |
117 | getPool('four', 'erasure', '', ['rbd']), | |
118 | getPool('four', 'erasure', 'ec_overwrites', ['rbd']) | |
119 | ]; | |
120 | spyOn(TestBed.inject(PoolService), 'list').and.callFake(() => of(mock.pools)); | |
121 | rbdServiceGetSpy = spyOn(TestBed.inject(RbdService), 'get'); | |
122 | mock.rbd = ({ pool_name: 'foo', pool_image: 'bar' } as any) as RbdFormResponseModel; | |
123 | rbdServiceGetSpy.and.returnValue(of(mock.rbd)); | |
eafe8130 TL |
124 | component.mode = undefined; |
125 | }); | |
126 | ||
127 | it('should create image', () => { | |
128 | component.ngOnInit(); | |
129 | component.submit(); | |
130 | ||
131 | expect(createAction).toHaveBeenCalledTimes(1); | |
132 | expect(editAction).toHaveBeenCalledTimes(0); | |
133 | expect(cloneAction).toHaveBeenCalledTimes(0); | |
134 | expect(copyAction).toHaveBeenCalledTimes(0); | |
1911f103 TL |
135 | expect(routerNavigate).toHaveBeenCalledTimes(1); |
136 | }); | |
137 | ||
138 | it('should unsubscribe right after image data is received', () => { | |
f67539c2 TL |
139 | setRouterUrl('edit', 'foo', 'bar'); |
140 | rbdServiceGetSpy.and.returnValue(of(mock.rbd)); | |
1911f103 | 141 | editAction.and.returnValue(NEVER); |
1911f103 | 142 | expect(component['rbdImage'].observers.length).toEqual(0); |
f67539c2 TL |
143 | component.ngOnInit(); // Subscribes to image once during init |
144 | component.submit(); | |
145 | expect(component['rbdImage'].observers.length).toEqual(1); | |
1911f103 TL |
146 | expect(createAction).toHaveBeenCalledTimes(0); |
147 | expect(editAction).toHaveBeenCalledTimes(1); | |
148 | expect(cloneAction).toHaveBeenCalledTimes(0); | |
149 | expect(copyAction).toHaveBeenCalledTimes(0); | |
150 | expect(routerNavigate).toHaveBeenCalledTimes(0); | |
eafe8130 TL |
151 | }); |
152 | ||
153 | it('should not edit image if no image data is received', fakeAsync(() => { | |
f67539c2 TL |
154 | setRouterUrl('edit', 'foo', 'bar'); |
155 | rbdServiceGetSpy.and.returnValue(of(mock.rbd).pipe(delay(DELAY))); | |
eafe8130 TL |
156 | component.ngOnInit(); |
157 | component.submit(); | |
158 | ||
159 | expect(createAction).toHaveBeenCalledTimes(0); | |
160 | expect(editAction).toHaveBeenCalledTimes(0); | |
161 | expect(cloneAction).toHaveBeenCalledTimes(0); | |
162 | expect(copyAction).toHaveBeenCalledTimes(0); | |
1911f103 | 163 | expect(routerNavigate).toHaveBeenCalledTimes(0); |
eafe8130 | 164 | |
1911f103 | 165 | tick(DELAY); |
eafe8130 TL |
166 | })); |
167 | ||
f67539c2 TL |
168 | describe('disable data pools', () => { |
169 | beforeEach(() => { | |
170 | component.ngOnInit(); | |
171 | }); | |
172 | ||
173 | it('should be enabled with more than 1 pool', () => { | |
174 | component['handleExternalData'](mock); | |
175 | expect(component.allDataPools.length).toBe(3); | |
176 | expect(component.rbdForm.get('useDataPool').disabled).toBe(false); | |
177 | ||
178 | mock.pools.pop(); | |
179 | component['handleExternalData'](mock); | |
180 | expect(component.allDataPools.length).toBe(2); | |
181 | expect(component.rbdForm.get('useDataPool').disabled).toBe(false); | |
182 | }); | |
183 | ||
184 | it('should be disabled with 1 pool', () => { | |
185 | mock.pools = [mock.pools[0]]; | |
186 | component['handleExternalData'](mock); | |
187 | expect(component.rbdForm.get('useDataPool').disabled).toBe(true); | |
188 | }); | |
189 | ||
190 | // Reason for 2 tests - useDataPool is not re-enabled anywhere else | |
191 | it('should be disabled without any pool', () => { | |
192 | mock.pools = []; | |
193 | component['handleExternalData'](mock); | |
194 | expect(component.rbdForm.get('useDataPool').disabled).toBe(true); | |
195 | }); | |
196 | }); | |
197 | ||
eafe8130 | 198 | it('should edit image after image data is received', () => { |
f67539c2 | 199 | setRouterUrl('edit', 'foo', 'bar'); |
eafe8130 TL |
200 | component.ngOnInit(); |
201 | component.submit(); | |
202 | ||
203 | expect(createAction).toHaveBeenCalledTimes(0); | |
204 | expect(editAction).toHaveBeenCalledTimes(1); | |
205 | expect(cloneAction).toHaveBeenCalledTimes(0); | |
206 | expect(copyAction).toHaveBeenCalledTimes(0); | |
1911f103 | 207 | expect(routerNavigate).toHaveBeenCalledTimes(1); |
eafe8130 TL |
208 | }); |
209 | ||
210 | it('should not clone image if no image data is received', fakeAsync(() => { | |
f67539c2 TL |
211 | setRouterUrl('clone', 'foo', 'bar'); |
212 | rbdServiceGetSpy.and.returnValue(of(mock.rbd).pipe(delay(DELAY))); | |
eafe8130 TL |
213 | component.ngOnInit(); |
214 | component.submit(); | |
215 | ||
216 | expect(createAction).toHaveBeenCalledTimes(0); | |
217 | expect(editAction).toHaveBeenCalledTimes(0); | |
218 | expect(cloneAction).toHaveBeenCalledTimes(0); | |
219 | expect(copyAction).toHaveBeenCalledTimes(0); | |
1911f103 | 220 | expect(routerNavigate).toHaveBeenCalledTimes(0); |
eafe8130 | 221 | |
1911f103 | 222 | tick(DELAY); |
eafe8130 TL |
223 | })); |
224 | ||
225 | it('should clone image after image data is received', () => { | |
f67539c2 | 226 | setRouterUrl('clone', 'foo', 'bar'); |
eafe8130 TL |
227 | component.ngOnInit(); |
228 | component.submit(); | |
229 | ||
230 | expect(createAction).toHaveBeenCalledTimes(0); | |
231 | expect(editAction).toHaveBeenCalledTimes(0); | |
232 | expect(cloneAction).toHaveBeenCalledTimes(1); | |
233 | expect(copyAction).toHaveBeenCalledTimes(0); | |
1911f103 | 234 | expect(routerNavigate).toHaveBeenCalledTimes(1); |
eafe8130 TL |
235 | }); |
236 | ||
237 | it('should not copy image if no image data is received', fakeAsync(() => { | |
f67539c2 TL |
238 | setRouterUrl('copy', 'foo', 'bar'); |
239 | rbdServiceGetSpy.and.returnValue(of(mock.rbd).pipe(delay(DELAY))); | |
eafe8130 TL |
240 | component.ngOnInit(); |
241 | component.submit(); | |
242 | ||
243 | expect(createAction).toHaveBeenCalledTimes(0); | |
244 | expect(editAction).toHaveBeenCalledTimes(0); | |
245 | expect(cloneAction).toHaveBeenCalledTimes(0); | |
246 | expect(copyAction).toHaveBeenCalledTimes(0); | |
1911f103 | 247 | expect(routerNavigate).toHaveBeenCalledTimes(0); |
eafe8130 | 248 | |
1911f103 | 249 | tick(DELAY); |
eafe8130 TL |
250 | })); |
251 | ||
252 | it('should copy image after image data is received', () => { | |
f67539c2 | 253 | setRouterUrl('copy', 'foo', 'bar'); |
eafe8130 TL |
254 | component.ngOnInit(); |
255 | component.submit(); | |
256 | ||
257 | expect(createAction).toHaveBeenCalledTimes(0); | |
258 | expect(editAction).toHaveBeenCalledTimes(0); | |
259 | expect(cloneAction).toHaveBeenCalledTimes(0); | |
260 | expect(copyAction).toHaveBeenCalledTimes(1); | |
1911f103 | 261 | expect(routerNavigate).toHaveBeenCalledTimes(1); |
eafe8130 TL |
262 | }); |
263 | }); | |
264 | ||
11fdf7f2 TL |
265 | describe('should test decodeURIComponent of params', () => { |
266 | let rbdService: RbdService; | |
267 | ||
268 | beforeEach(() => { | |
f67539c2 | 269 | rbdService = TestBed.inject(RbdService); |
11fdf7f2 TL |
270 | component.mode = RbdFormMode.editing; |
271 | fixture.detectChanges(); | |
272 | spyOn(rbdService, 'get').and.callThrough(); | |
273 | }); | |
274 | ||
9f95a23c TL |
275 | it('with namespace', () => { |
276 | activatedRoute.setParams({ image_spec: 'foo%2Fbar%2Fbaz' }); | |
277 | ||
278 | expect(rbdService.get).toHaveBeenCalledWith(new ImageSpec('foo', 'bar', 'baz')); | |
279 | }); | |
280 | ||
11fdf7f2 | 281 | it('without snapName', () => { |
9f95a23c | 282 | activatedRoute.setParams({ image_spec: 'foo%2Fbar', snap: undefined }); |
11fdf7f2 | 283 | |
9f95a23c | 284 | expect(rbdService.get).toHaveBeenCalledWith(new ImageSpec('foo', null, 'bar')); |
11fdf7f2 TL |
285 | expect(component.snapName).toBeUndefined(); |
286 | }); | |
287 | ||
288 | it('with snapName', () => { | |
9f95a23c | 289 | activatedRoute.setParams({ image_spec: 'foo%2Fbar', snap: 'baz%2Fbaz' }); |
11fdf7f2 | 290 | |
9f95a23c | 291 | expect(rbdService.get).toHaveBeenCalledWith(new ImageSpec('foo', null, 'bar')); |
11fdf7f2 TL |
292 | expect(component.snapName).toBe('baz/baz'); |
293 | }); | |
294 | }); | |
295 | ||
296 | describe('test image configuration component', () => { | |
297 | it('is visible', () => { | |
298 | fixture.detectChanges(); | |
9f95a23c TL |
299 | expect( |
300 | fixture.debugElement.query(By.css('cd-rbd-configuration-form')).nativeElement.parentElement | |
301 | .hidden | |
302 | ).toBe(true); | |
494da23a TL |
303 | }); |
304 | }); | |
305 | ||
306 | describe('tests for feature flags', () => { | |
2a845540 | 307 | let deepFlatten: any, layering: any, exclusiveLock: any, objectMap: any, fastDiff: any; |
494da23a TL |
308 | const defaultFeatures = [ |
309 | // Supposed to be enabled by default | |
310 | 'deep-flatten', | |
311 | 'exclusive-lock', | |
312 | 'fast-diff', | |
313 | 'layering', | |
314 | 'object-map' | |
315 | ]; | |
316 | const allFeatureNames = [ | |
317 | 'deep-flatten', | |
318 | 'layering', | |
319 | 'exclusive-lock', | |
320 | 'object-map', | |
494da23a TL |
321 | 'fast-diff' |
322 | ]; | |
9f95a23c | 323 | const setFeatures = (features: Record<string, RbdImageFeature>) => { |
494da23a TL |
324 | component.features = features; |
325 | component.featuresList = component.objToArray(features); | |
326 | component.createForm(); | |
327 | }; | |
328 | const getFeatureNativeElements = () => allFeatureNames.map((f) => queryNativeElement(`#${f}`)); | |
329 | ||
330 | it('should convert feature flags correctly in the constructor', () => { | |
331 | setFeatures({ | |
332 | one: { desc: 'one', allowEnable: true, allowDisable: true }, | |
333 | two: { desc: 'two', allowEnable: true, allowDisable: true }, | |
334 | three: { desc: 'three', allowEnable: true, allowDisable: true } | |
335 | }); | |
336 | expect(component.featuresList).toEqual([ | |
337 | { desc: 'one', key: 'one', allowDisable: true, allowEnable: true }, | |
338 | { desc: 'two', key: 'two', allowDisable: true, allowEnable: true }, | |
339 | { desc: 'three', key: 'three', allowDisable: true, allowEnable: true } | |
340 | ]); | |
341 | }); | |
342 | ||
343 | describe('test edit form flags', () => { | |
344 | const prepare = (pool: string, image: string, enabledFeatures: string[]): void => { | |
f67539c2 | 345 | const rbdService = TestBed.inject(RbdService); |
494da23a TL |
346 | spyOn(rbdService, 'get').and.returnValue( |
347 | of({ | |
348 | name: image, | |
349 | pool_name: pool, | |
350 | features_name: enabledFeatures | |
351 | }) | |
352 | ); | |
353 | spyOn(rbdService, 'defaultFeatures').and.returnValue(of(defaultFeatures)); | |
f67539c2 | 354 | setRouterUrl('edit', pool, image); |
494da23a | 355 | fixture.detectChanges(); |
2a845540 | 356 | [deepFlatten, layering, exclusiveLock, objectMap, fastDiff] = getFeatureNativeElements(); |
494da23a TL |
357 | }; |
358 | ||
359 | it('should have the interlock feature for flags disabled, if one feature is not set', () => { | |
360 | prepare('rbd', 'foobar', ['deep-flatten', 'exclusive-lock', 'layering', 'object-map']); | |
361 | ||
362 | expect(objectMap.disabled).toBe(false); | |
363 | expect(fastDiff.disabled).toBe(false); | |
364 | ||
365 | expect(objectMap.checked).toBe(true); | |
366 | expect(fastDiff.checked).toBe(false); | |
367 | ||
368 | fastDiff.click(); | |
369 | fastDiff.click(); | |
370 | ||
371 | expect(objectMap.checked).toBe(true); // Shall not be disabled by `fast-diff`! | |
372 | }); | |
373 | ||
374 | it('should not disable object-map when fast-diff is unchecked', () => { | |
375 | prepare('rbd', 'foobar', ['deep-flatten', 'exclusive-lock', 'layering', 'object-map']); | |
376 | ||
377 | fastDiff.click(); | |
378 | fastDiff.click(); | |
379 | ||
380 | expect(objectMap.checked).toBe(true); // Shall not be disabled by `fast-diff`! | |
381 | }); | |
382 | ||
383 | it('should not enable fast-diff when object-map is checked', () => { | |
384 | prepare('rbd', 'foobar', ['deep-flatten', 'exclusive-lock', 'layering', 'object-map']); | |
385 | ||
386 | objectMap.click(); | |
387 | objectMap.click(); | |
388 | ||
389 | expect(fastDiff.checked).toBe(false); // Shall not be disabled by `fast-diff`! | |
390 | }); | |
391 | }); | |
392 | ||
393 | describe('test create form flags', () => { | |
394 | beforeEach(() => { | |
f67539c2 | 395 | const rbdService = TestBed.inject(RbdService); |
494da23a | 396 | spyOn(rbdService, 'defaultFeatures').and.returnValue(of(defaultFeatures)); |
f67539c2 | 397 | setRouterUrl('create'); |
494da23a | 398 | fixture.detectChanges(); |
2a845540 | 399 | [deepFlatten, layering, exclusiveLock, objectMap, fastDiff] = getFeatureNativeElements(); |
494da23a TL |
400 | }); |
401 | ||
402 | it('should initialize the checkboxes correctly', () => { | |
403 | expect(deepFlatten.disabled).toBe(false); | |
404 | expect(layering.disabled).toBe(false); | |
405 | expect(exclusiveLock.disabled).toBe(false); | |
406 | expect(objectMap.disabled).toBe(false); | |
494da23a TL |
407 | expect(fastDiff.disabled).toBe(false); |
408 | ||
409 | expect(deepFlatten.checked).toBe(true); | |
410 | expect(layering.checked).toBe(true); | |
411 | expect(exclusiveLock.checked).toBe(true); | |
412 | expect(objectMap.checked).toBe(true); | |
494da23a TL |
413 | expect(fastDiff.checked).toBe(true); |
414 | }); | |
415 | ||
416 | it('should disable features if their requirements are not met (exclusive-lock)', () => { | |
417 | exclusiveLock.click(); // unchecks exclusive-lock | |
418 | expect(objectMap.disabled).toBe(true); | |
494da23a TL |
419 | expect(fastDiff.disabled).toBe(true); |
420 | }); | |
421 | ||
422 | it('should disable features if their requirements are not met (object-map)', () => { | |
423 | objectMap.click(); // unchecks object-map | |
424 | expect(fastDiff.disabled).toBe(true); | |
425 | }); | |
11fdf7f2 | 426 | }); |
2a845540 TL |
427 | |
428 | describe('test mirroring options', () => { | |
429 | beforeEach(() => { | |
430 | component.ngOnInit(); | |
431 | fixture.detectChanges(); | |
432 | const mirroring = fixture.debugElement.query(By.css('#mirroring')).nativeElement; | |
433 | mirroring.click(); | |
434 | fixture.detectChanges(); | |
435 | }); | |
436 | ||
437 | it('should verify two mirroring options are shown', () => { | |
438 | const journal = fixture.debugElement.query(By.css('#journal')).nativeElement; | |
439 | const snapshot = fixture.debugElement.query(By.css('#snapshot')).nativeElement; | |
440 | expect(journal).not.toBeNull(); | |
441 | expect(snapshot).not.toBeNull(); | |
442 | }); | |
443 | ||
444 | it('should verify only snapshot is disabled for pools that are in pool mirror mode', () => { | |
445 | component.poolMirrorMode = 'pool'; | |
446 | fixture.detectChanges(); | |
447 | const journal = fixture.debugElement.query(By.css('#journal')).nativeElement; | |
448 | const snapshot = fixture.debugElement.query(By.css('#snapshot')).nativeElement; | |
449 | expect(journal.disabled).toBe(false); | |
450 | expect(snapshot.disabled).toBe(true); | |
451 | }); | |
452 | ||
453 | it('should set and disable exclusive-lock only for the journal mode', () => { | |
454 | component.poolMirrorMode = 'pool'; | |
455 | fixture.detectChanges(); | |
456 | const exclusiveLocks = fixture.debugElement.query(By.css('#exclusive-lock')).nativeElement; | |
457 | expect(exclusiveLocks.checked).toBe(true); | |
458 | expect(exclusiveLocks.disabled).toBe(true); | |
459 | }); | |
460 | }); | |
11fdf7f2 TL |
461 | }); |
462 | }); |